quantplay 2.1.134__tar.gz → 2.1.136__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 (97) hide show
  1. {quantplay-2.1.134 → quantplay-2.1.136}/PKG-INFO +1 -1
  2. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/jainam_xts.py +27 -2
  3. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/kotak_ws.py +27 -5
  4. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts.py +10 -2
  5. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/PKG-INFO +1 -1
  6. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/SOURCES.txt +3 -0
  7. {quantplay-2.1.134 → quantplay-2.1.136}/setup.py +1 -1
  8. quantplay-2.1.136/tests/test_deltax_positions.py +178 -0
  9. quantplay-2.1.136/tests/test_instrument_freshness.py +347 -0
  10. quantplay-2.1.136/tests/test_kotak_order_feed_url.py +39 -0
  11. {quantplay-2.1.134 → quantplay-2.1.136}/README.md +0 -0
  12. {quantplay-2.1.134 → quantplay-2.1.136}/pyproject.toml +0 -0
  13. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/__init__.py +0 -0
  14. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/__init__.py +0 -0
  15. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue.py +0 -0
  16. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_utils/__init__.py +0 -0
  17. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_utils/aliceblue_oauth.py +0 -0
  18. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_v2.py +0 -0
  19. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone.py +0 -0
  20. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone_utils/__init__.py +0 -0
  21. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone_utils/angeloneWS.py +0 -0
  22. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/auto_login/__init__.py +0 -0
  23. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/auto_login/aliceblue.py +0 -0
  24. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/breeze/__init__.py +0 -0
  25. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/breeze/breeze_utils.py +0 -0
  26. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/broker_factory.py +0 -0
  27. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/dhan.py +0 -0
  28. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/__init__.py +0 -0
  29. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/fa_noren.py +0 -0
  30. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/finvasia_oauth.py +0 -0
  31. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/five_paisa.py +0 -0
  32. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/flattrade.py +0 -0
  33. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/__init__.py +0 -0
  34. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/flattrade_utils.py +0 -0
  35. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/ft_noren.py +0 -0
  36. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/generics/__init__.py +0 -0
  37. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/generics/broker.py +0 -0
  38. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/icici_direct.py +0 -0
  39. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/iifl_xts.py +0 -0
  40. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kite_utils.py +0 -0
  41. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak.py +0 -0
  42. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/__init__.py +0 -0
  43. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/kotak_ws_lib.py +0 -0
  44. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/motilal.py +0 -0
  45. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/noren.py +0 -0
  46. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/shoonya.py +0 -0
  47. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/uplink/__init__.py +0 -0
  48. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/uplink/uplink_utils.py +0 -0
  49. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/upstox.py +0 -0
  50. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/Connect.py +0 -0
  51. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/Exception.py +0 -0
  52. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/InteractiveSocketClient.py +0 -0
  53. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/__init__.py +0 -0
  54. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/ConnectV2.py +0 -0
  55. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/InteractiveSocketClientV2.py +0 -0
  56. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/__init__.py +0 -0
  57. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/zerodha.py +0 -0
  58. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/core/__init__.py +0 -0
  59. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/core/strategy.py +0 -0
  60. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/exception/__init__.py +0 -0
  61. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/exception/exceptions.py +0 -0
  62. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/indicator/__init__.py +0 -0
  63. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/indicator/iv.py +0 -0
  64. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/__init__.py +0 -0
  65. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/broker.py +0 -0
  66. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/broker_response.py +0 -0
  67. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/generics.py +0 -0
  68. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/instrument_data.py +0 -0
  69. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/order_event.py +0 -0
  70. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/py.typed +0 -0
  71. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/__init__.py +0 -0
  72. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/iv_spike.py +0 -0
  73. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/obuy.py +0 -0
  74. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/__init__.py +0 -0
  75. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/caching.py +0 -0
  76. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/constant.py +0 -0
  77. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/exchange.py +0 -0
  78. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/number_utils.py +0 -0
  79. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/pickle_utils.py +0 -0
  80. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/redaction.py +0 -0
  81. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/selenium_utils.py +0 -0
  82. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/__init__.py +0 -0
  83. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/aws/__init__.py +0 -0
  84. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/aws/s3.py +0 -0
  85. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/redis.py +0 -0
  86. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/dependency_links.txt +0 -0
  87. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/requires.txt +0 -0
  88. {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/top_level.txt +0 -0
  89. {quantplay-2.1.134 → quantplay-2.1.136}/setup.cfg +0 -0
  90. {quantplay-2.1.134 → quantplay-2.1.136}/tests/__init__.py +0 -0
  91. {quantplay-2.1.134 → quantplay-2.1.136}/tests/conftest.py +0 -0
  92. {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_deltax_root_url.py +0 -0
  93. {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_jainam_xts.py +0 -0
  94. {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_log_redaction.py +0 -0
  95. {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/__init__.py +0 -0
  96. {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/aws/__init__.py +0 -0
  97. {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/aws/s3_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantplay
3
- Version: 2.1.134
3
+ Version: 2.1.136
4
4
  Summary: This python package will be stored in AWS CodeArtifact
5
5
  Home-page:
6
6
  Author:
@@ -1,5 +1,5 @@
1
1
  import traceback
2
- from typing import Literal
2
+ from typing import Any, Literal
3
3
 
4
4
  import polars as pl
5
5
 
@@ -88,6 +88,15 @@ class Jainam(XTS):
88
88
  # on the dealer orderbook having orders.
89
89
  self._login_client_code: str | None = sub_client_id
90
90
 
91
+ # Classify the host we will actually connect to, so an explicitly
92
+ # supplied Jainam URL (or an empty string falling back to one) is not
93
+ # mistaken for a custom host. Computed here rather than from
94
+ # self.root_url later: set_wrapper overwrites root_url with the
95
+ # server-assigned connection string (".../9hostlookup"), which no
96
+ # longer matches XTS_ROOT_URL_MAP.
97
+ resolved_root_url = root_url or XTS_ROOT_URL_MAP[XTS_type]
98
+ self._custom_root_url = resolved_root_url not in XTS_ROOT_URL_MAP.values()
99
+
91
100
  # XTS type C dealers route through the broker's own infra; an
92
101
  # outbound proxy is unnecessary and just adds latency/failure modes.
93
102
  if XTS_type == "C":
@@ -97,7 +106,7 @@ class Jainam(XTS):
97
106
  # Other brokers (Delta X Broking) run the same XTS protocol on
98
107
  # their own server; they carry root_url in broker_data. Accounts
99
108
  # without one keep resolving through the Jainam host map.
100
- root_url=root_url or XTS_ROOT_URL_MAP[XTS_type],
109
+ root_url=resolved_root_url,
101
110
  api_key=api_key,
102
111
  api_secret=api_secret,
103
112
  md_api_key=md_api_key,
@@ -252,6 +261,22 @@ class Jainam(XTS):
252
261
  def stream_order_updates(self) -> None:
253
262
  self.stream_order_updates_legacy()
254
263
 
264
+ def positions_api_response(self) -> dict[str, Any]:
265
+ """Delta X Broking's dealer-position endpoint is a no-op.
266
+
267
+ It answers "success" with an empty positionList for every clientID,
268
+ so a dealer on that host shows a permanently flat book. Its positions
269
+ come from the regular endpoint keyed by the dealer's client code.
270
+ Jainam's own hosts keep using the dealer endpoint.
271
+ """
272
+ if self._custom_root_url and self.is_dealer and self._login_client_code:
273
+ return self.invoke_xts_api(
274
+ self.wrapper.get_position_netwise,
275
+ clientID=self._login_client_code,
276
+ )
277
+
278
+ return super().positions_api_response()
279
+
255
280
  def orders(self, tag: str | None = None, add_ltp: bool = True) -> pl.DataFrame:
256
281
  orders = super().orders(tag, add_ltp)
257
282
  orders = orders.with_columns(
@@ -10,6 +10,32 @@ from quantplay.broker.kotak_utils.kotak_ws_lib import HSIWebSocket
10
10
 
11
11
  ORDER_FEED_URL = "wss://mis.kotaksecurities.com/realtime"
12
12
  ORDER_FEED_URL_ADC = "wss://cis.kotaksecurities.com/realtime"
13
+ ORDER_FEED_URL_E21 = "wss://e21.kotaksecurities.com/realtime"
14
+ ORDER_FEED_URL_E22 = "wss://e22.kotaksecurities.com/realtime"
15
+ ORDER_FEED_URL_E41 = "wss://e41.kotaksecurities.com/realtime"
16
+ ORDER_FEED_URL_E43 = "wss://e43.kotaksecurities.com/realtime"
17
+
18
+ # The order feed is served per data centre. Only "adc" used to be mapped, so
19
+ # every other data centre silently fell back to the generic endpoint: the
20
+ # socket connects, the CONNECTION frame is acknowledged, and then no order
21
+ # event is ever delivered (the heartbeat is rejected too). Verified against a
22
+ # live dataCenter="E41" account on 2026-09-01 - identical orders produced 0
23
+ # events on the default URL and the full lifecycle on e41. Mapping matches
24
+ # neo_api_client.NeoWebSocket.start_hsi_websocket.
25
+ DATA_CENTER_ORDER_FEED_URLS = {
26
+ "adc": ORDER_FEED_URL_ADC,
27
+ "e21": ORDER_FEED_URL_E21,
28
+ "e22": ORDER_FEED_URL_E22,
29
+ "e41": ORDER_FEED_URL_E41,
30
+ "e43": ORDER_FEED_URL_E43,
31
+ }
32
+
33
+
34
+ def order_feed_url(data_center: str | None) -> str:
35
+ if not data_center:
36
+ return ORDER_FEED_URL
37
+
38
+ return DATA_CENTER_ORDER_FEED_URLS.get(data_center.lower(), ORDER_FEED_URL)
13
39
 
14
40
 
15
41
  class NeoWebSocket:
@@ -96,11 +122,7 @@ class NeoWebSocket:
96
122
  self.hsiWebsocket.send(json.dumps(payload))
97
123
 
98
124
  def start_hsi_websocket(self) -> None:
99
- url = ORDER_FEED_URL
100
-
101
- if self.data_center:
102
- if self.data_center.lower() == "adc":
103
- url = ORDER_FEED_URL_ADC
125
+ url = order_feed_url(self.data_center)
104
126
 
105
127
  self.hsiWebsocket = HSIWebSocket()
106
128
  self.hsiWebsocket.open_connection(
@@ -423,8 +423,13 @@ class XTS(Broker):
423
423
  def holdings(self, add_ltp: bool = True) -> pl.DataFrame:
424
424
  return pl.DataFrame(schema=self.holidings_schema)
425
425
 
426
- def positions(self, drop_cnc: bool = True, add_ltp: bool = True) -> pl.DataFrame:
427
- api_response = self.invoke_xts_api(
426
+ def positions_api_response(self) -> dict[str, Any]:
427
+ """Fetch the raw position payload.
428
+
429
+ Split out so brokers whose XTS build routes positions differently
430
+ can override the call without duplicating the parsing below.
431
+ """
432
+ return self.invoke_xts_api(
428
433
  (
429
434
  self.wrapper.get_dealerposition_netwise
430
435
  if self.is_dealer
@@ -433,6 +438,9 @@ class XTS(Broker):
433
438
  clientID=None if self.is_dealer else self.ClientID,
434
439
  )
435
440
 
441
+ def positions(self, drop_cnc: bool = True, add_ltp: bool = True) -> pl.DataFrame:
442
+ api_response = self.positions_api_response()
443
+
436
444
  api_response = api_response["result"]["positionList"]
437
445
  positions_df = pl.DataFrame(api_response)
438
446
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantplay
3
- Version: 2.1.134
3
+ Version: 2.1.136
4
4
  Summary: This python package will be stored in AWS CodeArtifact
5
5
  Home-page:
6
6
  Author:
@@ -84,8 +84,11 @@ quantplay/wrapper/aws/__init__.py
84
84
  quantplay/wrapper/aws/s3.py
85
85
  tests/__init__.py
86
86
  tests/conftest.py
87
+ tests/test_deltax_positions.py
87
88
  tests/test_deltax_root_url.py
89
+ tests/test_instrument_freshness.py
88
90
  tests/test_jainam_xts.py
91
+ tests/test_kotak_order_feed_url.py
89
92
  tests/test_log_redaction.py
90
93
  tests/wrapper/__init__.py
91
94
  tests/wrapper/aws/__init__.py
@@ -21,7 +21,7 @@ requirements = [
21
21
  setup(
22
22
  name="quantplay",
23
23
  long_description=Path("README.md").read_text(),
24
- version="2.1.134",
24
+ version="2.1.136",
25
25
  install_requires=requirements,
26
26
  tests_require=[],
27
27
  packages=find_packages(),
@@ -0,0 +1,178 @@
1
+ """Delta X Broking's dealer-position endpoint is a no-op.
2
+
3
+ `get_dealerposition_netwise` answers `success` with an empty positionList
4
+ for every clientID, so a Delta X dealer shows a permanently flat book.
5
+ Positions only come back from the regular endpoint keyed by the dealer's
6
+ client code (PRO17).
7
+
8
+ Jainam dealers must keep using the dealer endpoint, so every behaviour
9
+ here has a sibling asserting the no-root_url path is untouched.
10
+ """
11
+
12
+ from typing import Any
13
+ from unittest.mock import MagicMock
14
+
15
+ import pytest
16
+
17
+ from quantplay.broker import jainam_xts
18
+ from quantplay.broker.jainam_xts import XTS_ROOT_URL_MAP, Jainam
19
+
20
+ DELTAX_ROOT = "http://oms.deltaxbroking.com:3000"
21
+ DELTAX_CLIENT = "PRO17"
22
+ JAINAM_SUB = "JSR129"
23
+
24
+
25
+ def build(
26
+ custom_root: bool,
27
+ is_dealer: bool = True,
28
+ sub_client_id: str | None = None,
29
+ XTS_type: str = "C",
30
+ ) -> tuple[Jainam, MagicMock]:
31
+ """A Jainam with login and instrument loading bypassed."""
32
+ broker = object.__new__(Jainam)
33
+ broker.XTS_type = XTS_type # type: ignore[assignment]
34
+ broker.is_dealer = is_dealer
35
+ broker.ClientID = "*****" if (is_dealer and XTS_type != "C") else "BRS03"
36
+ broker._custom_root_url = custom_root # pyright: ignore[reportPrivateUsage]
37
+ broker._login_client_code = sub_client_id # pyright: ignore[reportPrivateUsage]
38
+ broker.dealer_client_id = None
39
+
40
+ wrapper = MagicMock()
41
+ wrapper.userID = "BRS03"
42
+ wrapper.get_dealerposition_netwise.return_value = {
43
+ "type": "success",
44
+ "result": {"positionList": []},
45
+ }
46
+ wrapper.get_position_netwise.return_value = {
47
+ "type": "success",
48
+ "result": {"positionList": [{"TradingSymbol": "X"}]},
49
+ }
50
+ broker.wrapper = wrapper
51
+
52
+ # invoke_xts_api wraps the call in retry/error handling; call straight through.
53
+ broker.invoke_xts_api = lambda fn, *a, **kw: fn(*a, **kw) # type: ignore[method-assign]
54
+
55
+ return broker, wrapper
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Delta X: regular endpoint keyed by the dealer's client code
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def test_custom_host_dealer_reads_positions_from_the_regular_endpoint():
64
+ broker, wrapper = build(custom_root=True, sub_client_id=DELTAX_CLIENT)
65
+
66
+ broker.positions_api_response()
67
+
68
+ wrapper.get_position_netwise.assert_called_once_with(clientID=DELTAX_CLIENT)
69
+ wrapper.get_dealerposition_netwise.assert_not_called()
70
+
71
+
72
+ def test_custom_host_dealer_works_when_type_is_b():
73
+ """BRS03 runs as type B, so dealer_client_id is never resolved."""
74
+ broker, wrapper = build(
75
+ custom_root=True, sub_client_id=DELTAX_CLIENT, XTS_type="B"
76
+ )
77
+
78
+ broker.positions_api_response()
79
+
80
+ wrapper.get_position_netwise.assert_called_once_with(clientID=DELTAX_CLIENT)
81
+
82
+
83
+ def test_custom_host_without_a_client_code_keeps_the_dealer_endpoint():
84
+ """No client code to key on — don't silently send None to the wrong API."""
85
+ broker, wrapper = build(custom_root=True, sub_client_id=None)
86
+
87
+ broker.positions_api_response()
88
+
89
+ wrapper.get_dealerposition_netwise.assert_called_once_with(clientID=None)
90
+ wrapper.get_position_netwise.assert_not_called()
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Regression guards — JSR129A42 / JSR129A43 must not change
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def test_jainam_dealer_still_uses_the_dealer_endpoint():
99
+ broker, wrapper = build(custom_root=False, sub_client_id=JAINAM_SUB)
100
+
101
+ broker.positions_api_response()
102
+
103
+ wrapper.get_dealerposition_netwise.assert_called_once_with(clientID=None)
104
+ wrapper.get_position_netwise.assert_not_called()
105
+
106
+
107
+ def test_jainam_non_dealer_still_uses_its_own_client_id():
108
+ broker, wrapper = build(custom_root=False, is_dealer=False)
109
+
110
+ broker.positions_api_response()
111
+
112
+ wrapper.get_position_netwise.assert_called_once_with(clientID=broker.ClientID)
113
+ wrapper.get_dealerposition_netwise.assert_not_called()
114
+
115
+
116
+ class FakeConnect:
117
+ """Stands in for XTSConnectV2 so __init__ runs the real login path."""
118
+
119
+ def __init__(self, apiKey: str, secretKey: str, root: str) -> None:
120
+ self.root = root
121
+ self.token = None
122
+ self.userID = "JSR129A42"
123
+
124
+ def session_login(self, root: str) -> dict[str, Any]:
125
+ return {
126
+ "type": "success",
127
+ "result": {
128
+ "token": "t",
129
+ "userID": "JSR129A42",
130
+ "isInvestorClient": False,
131
+ "clientCodes": [JAINAM_SUB],
132
+ },
133
+ }
134
+
135
+ def marketdata_login(self) -> dict[str, Any]:
136
+ return {"type": "success", "result": {"token": "m"}}
137
+
138
+
139
+ def login_jainam(monkeypatch: Any, **kwargs: Any) -> Jainam:
140
+ monkeypatch.setattr(jainam_xts, "XTSConnectV2", FakeConnect)
141
+ return Jainam(
142
+ api_key="k",
143
+ api_secret="s",
144
+ md_api_key="mk",
145
+ md_api_secret="ms",
146
+ is_dealer=True,
147
+ load_instrument=False,
148
+ **kwargs,
149
+ )
150
+
151
+
152
+ @pytest.mark.parametrize(
153
+ "root_url",
154
+ [
155
+ pytest.param(XTS_ROOT_URL_MAP["A"], id="explicit-jainam-a"),
156
+ pytest.param(XTS_ROOT_URL_MAP["C"], id="explicit-jainam-c"),
157
+ pytest.param("", id="empty-string-falls-back"),
158
+ pytest.param(None, id="omitted"),
159
+ ],
160
+ )
161
+ def test_hosts_resolving_to_jainam_are_never_custom(
162
+ monkeypatch: Any, root_url: str | None
163
+ ):
164
+ """Only a host outside XTS_ROOT_URL_MAP counts as custom.
165
+
166
+ An explicitly stored Jainam URL, and "" (falsy, so it falls back to the
167
+ map), both resolve to Jainam and must keep the dealer endpoint.
168
+ """
169
+ broker = login_jainam(monkeypatch, XTS_type="C", root_url=root_url)
170
+
171
+ assert broker._custom_root_url is False # pyright: ignore[reportPrivateUsage]
172
+ assert broker.root_url in XTS_ROOT_URL_MAP.values()
173
+
174
+
175
+ def test_a_non_jainam_host_is_custom(monkeypatch: Any):
176
+ broker = login_jainam(monkeypatch, XTS_type="C", root_url=DELTAX_ROOT)
177
+
178
+ assert broker._custom_root_url is True # pyright: ignore[reportPrivateUsage]
@@ -0,0 +1,347 @@
1
+ import os
2
+ import time
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ import pandas as pd
7
+ import pytest
8
+
9
+ import quantplay.utils.pickle_utils as pickle_utils_module
10
+ from quantplay.broker.broker_factory import (
11
+ Broker as BrokerName,
12
+ )
13
+ from quantplay.broker.broker_factory import (
14
+ BrokerFactory,
15
+ broker_instruments_map,
16
+ instrument_cache,
17
+ )
18
+ from quantplay.broker.generics.broker import Broker
19
+ from quantplay.broker.zerodha import Zerodha
20
+ from quantplay.exception.exceptions import StaleDataFound
21
+ from quantplay.utils.pickle_utils import InstrumentData, PickleUtils
22
+ from quantplay.wrapper.aws import s3 as s3_module
23
+ from quantplay.wrapper.aws.s3 import S3Utils
24
+
25
+
26
+ def unique_name(prefix: str) -> str:
27
+ return f"{prefix}_{uuid.uuid4().hex[:10]}"
28
+
29
+
30
+ class DummyBroker(Broker):
31
+ def get_exchange(self, exchange):
32
+ return exchange
33
+
34
+
35
+ DummyBroker.__abstractmethods__ = frozenset()
36
+
37
+
38
+ class FakeS3Client:
39
+ def __init__(self, response=None, error=None):
40
+ self.response = response
41
+ self.error = error
42
+
43
+ def head_object(self, Bucket, Key):
44
+ if self.error is not None:
45
+ raise self.error
46
+ return self.response
47
+
48
+
49
+ @pytest.fixture
50
+ def cleanup_paths():
51
+ paths = []
52
+ yield paths
53
+ for path in paths:
54
+ if os.path.exists(path):
55
+ os.remove(path)
56
+
57
+
58
+ @pytest.fixture(autouse=True)
59
+ def clear_instrument_cache():
60
+ instrument_cache.clear()
61
+ yield
62
+ instrument_cache.clear()
63
+
64
+
65
+ def make_pickle(file_name: str, data: dict) -> str:
66
+ PickleUtils.save_data(data, file_name)
67
+ return f"/tmp/{file_name}.pickle"
68
+
69
+
70
+ OLD_SYMBOL_DATA = {
71
+ "BFO:OLDSYMBOL": {
72
+ "broker_symbol": "OLDSYMBOL",
73
+ "tradingsymbol": "OLDSYMBOL",
74
+ "lot_size": 20,
75
+ }
76
+ }
77
+
78
+ NEW_SYMBOL_DATA = {
79
+ "BFO:SENSEX2671678400CE": {
80
+ "broker_symbol": "SENSEX2671678400CE",
81
+ "tradingsymbol": "SENSEX2671678400CE",
82
+ "lot_size": 20,
83
+ }
84
+ }
85
+
86
+
87
+ class TestS3LastModified:
88
+ def test_returns_datetime_on_success(self, monkeypatch):
89
+ expected = datetime(2026, 7, 16, 3, 16, tzinfo=timezone.utc)
90
+ monkeypatch.setattr(
91
+ s3_module.boto3,
92
+ "client",
93
+ lambda service: FakeS3Client(response={"LastModified": expected}),
94
+ )
95
+ assert S3Utils.last_modified("bucket", "key") == expected
96
+
97
+ def test_returns_none_on_failure(self, monkeypatch):
98
+ monkeypatch.setattr(
99
+ s3_module.boto3,
100
+ "client",
101
+ lambda service: FakeS3Client(error=Exception("404 Not Found")),
102
+ )
103
+ assert S3Utils.last_modified("bucket", "key") is None
104
+
105
+
106
+ class TestPickleUtils:
107
+ def test_save_data_is_atomic_on_failure(self, monkeypatch, cleanup_paths):
108
+ file_name = unique_name("atomic")
109
+ cleanup_paths.append(make_pickle(file_name, {"a": 1}))
110
+
111
+ def boom(*args, **kwargs):
112
+ raise RuntimeError("disk full")
113
+
114
+ monkeypatch.setattr(pickle_utils_module.pickle, "dump", boom)
115
+ with pytest.raises(Exception):
116
+ PickleUtils.save_data({"b": 2}, file_name)
117
+ monkeypatch.undo()
118
+
119
+ assert PickleUtils.load_data(file_name) == {"a": 1}
120
+
121
+ def test_instrument_data_reloads_when_pickle_changes(self, cleanup_paths):
122
+ file_name = unique_name("reload")
123
+ path = make_pickle(file_name, {"NSE:A": {"lot_size": 1}})
124
+ cleanup_paths.append(path)
125
+
126
+ instance = InstrumentData.get_instance()
127
+ assert instance.load_data(file_name) == {"NSE:A": {"lot_size": 1}}
128
+
129
+ make_pickle(file_name, {"NSE:B": {"lot_size": 2}})
130
+ new_time = time.time() + 10
131
+ os.utime(path, (new_time, new_time))
132
+
133
+ assert instance.load_data(file_name) == {"NSE:B": {"lot_size": 2}}
134
+
135
+ def test_instrument_data_serves_memory_copy_when_unchanged(self, cleanup_paths):
136
+ file_name = unique_name("cached")
137
+ path = make_pickle(file_name, {"NSE:A": {"lot_size": 1}})
138
+ cleanup_paths.append(path)
139
+
140
+ instance = InstrumentData.get_instance()
141
+ first = instance.load_data(file_name)
142
+ second = instance.load_data(file_name)
143
+ assert first is second
144
+
145
+
146
+ class TestGetLotSize:
147
+ def test_missing_symbol_raises_stale_data_found(self):
148
+ broker = DummyBroker()
149
+ broker.symbol_data = {}
150
+
151
+ with pytest.raises(StaleDataFound) as exc_info:
152
+ broker.get_lot_size("BFO", "SENSEX2671678400CE")
153
+
154
+ assert "BFO:SENSEX2671678400CE" in str(exc_info.value)
155
+
156
+ def test_returns_lot_size_when_symbol_exists(self):
157
+ broker = DummyBroker()
158
+ broker.symbol_data = NEW_SYMBOL_DATA
159
+
160
+ assert broker.get_lot_size("BFO", "SENSEX2671678400CE") == 20
161
+
162
+
163
+ class TestPickleFreshness:
164
+ def setup_method(self):
165
+ self.factory = BrokerFactory()
166
+
167
+ def test_missing_pickle_is_stale(self, monkeypatch):
168
+ monkeypatch.setattr(S3Utils, "last_modified", staticmethod(lambda b, k: None))
169
+ assert self.factory.is_instrument_pickle_fresh(unique_name("missing")) is False
170
+
171
+ def test_pickle_from_today_with_no_s3_csv_is_fresh(
172
+ self, monkeypatch, cleanup_paths
173
+ ):
174
+ file_name = unique_name("fresh")
175
+ cleanup_paths.append(make_pickle(file_name, OLD_SYMBOL_DATA))
176
+ monkeypatch.setattr(S3Utils, "last_modified", staticmethod(lambda b, k: None))
177
+
178
+ assert self.factory.is_instrument_pickle_fresh(file_name) is True
179
+
180
+ def test_pickle_from_yesterday_is_stale(self, monkeypatch, cleanup_paths):
181
+ file_name = unique_name("stale_date")
182
+ path = make_pickle(file_name, OLD_SYMBOL_DATA)
183
+ cleanup_paths.append(path)
184
+ yesterday = time.time() - 86400
185
+ os.utime(path, (yesterday, yesterday))
186
+ monkeypatch.setattr(S3Utils, "last_modified", staticmethod(lambda b, k: None))
187
+
188
+ assert self.factory.is_instrument_pickle_fresh(file_name) is False
189
+
190
+ def test_pickle_older_than_s3_csv_is_stale(self, monkeypatch, cleanup_paths):
191
+ file_name = unique_name("stale_s3")
192
+ cleanup_paths.append(make_pickle(file_name, OLD_SYMBOL_DATA))
193
+ s3_time = datetime.now(timezone.utc) + timedelta(hours=1)
194
+ monkeypatch.setattr(
195
+ S3Utils, "last_modified", staticmethod(lambda b, k: s3_time)
196
+ )
197
+
198
+ assert self.factory.is_instrument_pickle_fresh(file_name) is False
199
+
200
+ def test_pickle_newer_than_s3_csv_is_fresh(self, monkeypatch, cleanup_paths):
201
+ file_name = unique_name("fresh_s3")
202
+ cleanup_paths.append(make_pickle(file_name, OLD_SYMBOL_DATA))
203
+ s3_time = datetime.now(timezone.utc) - timedelta(hours=2)
204
+ monkeypatch.setattr(
205
+ S3Utils, "last_modified", staticmethod(lambda b, k: s3_time)
206
+ )
207
+
208
+ assert self.factory.is_instrument_pickle_fresh(file_name) is True
209
+
210
+
211
+ class TestSetBrokerInstruments:
212
+ def setup_method(self):
213
+ self.factory = BrokerFactory()
214
+
215
+ def _stale_pickle_setup(self, monkeypatch, cleanup_paths):
216
+ file_name = unique_name("alicebluev2_test")
217
+ monkeypatch.setitem(
218
+ broker_instruments_map, BrokerName.ALICEBLUE_V2, file_name
219
+ )
220
+ path = make_pickle(file_name, OLD_SYMBOL_DATA)
221
+ cleanup_paths.append(path)
222
+ yesterday = time.time() - 86400
223
+ os.utime(path, (yesterday, yesterday))
224
+ monkeypatch.setattr(S3Utils, "last_modified", staticmethod(lambda b, k: None))
225
+ return file_name
226
+
227
+ def test_stale_pickle_triggers_refresh(self, monkeypatch, cleanup_paths):
228
+ file_name = self._stale_pickle_setup(monkeypatch, cleanup_paths)
229
+ broker = DummyBroker()
230
+ refresh_calls = []
231
+
232
+ def fake_refresh(name):
233
+ refresh_calls.append(name)
234
+ broker.symbol_data = NEW_SYMBOL_DATA
235
+ broker.initialize_broker_symbol_map()
236
+
237
+ monkeypatch.setattr(broker, "refresh_instruments", fake_refresh)
238
+
239
+ result = self.factory.set_broker_instruments(
240
+ broker_name=BrokerName.ALICEBLUE_V2, broker=broker
241
+ )
242
+
243
+ assert refresh_calls == [file_name]
244
+ assert "BFO:SENSEX2671678400CE" in result.symbol_data
245
+ assert (
246
+ instrument_cache.get(f"{BrokerName.ALICEBLUE_V2}_instruments")
247
+ == NEW_SYMBOL_DATA
248
+ )
249
+
250
+ def test_refresh_failure_serves_stale_pickle(self, monkeypatch, cleanup_paths):
251
+ self._stale_pickle_setup(monkeypatch, cleanup_paths)
252
+ broker = DummyBroker()
253
+
254
+ def fake_refresh(name):
255
+ raise Exception("s3 is down")
256
+
257
+ monkeypatch.setattr(broker, "refresh_instruments", fake_refresh)
258
+
259
+ result = self.factory.set_broker_instruments(
260
+ broker_name=BrokerName.ALICEBLUE_V2, broker=broker
261
+ )
262
+
263
+ assert "BFO:OLDSYMBOL" in result.symbol_data
264
+
265
+ def test_fresh_pickle_skips_refresh(self, monkeypatch, cleanup_paths):
266
+ file_name = unique_name("alicebluev2_test")
267
+ monkeypatch.setitem(
268
+ broker_instruments_map, BrokerName.ALICEBLUE_V2, file_name
269
+ )
270
+ cleanup_paths.append(make_pickle(file_name, OLD_SYMBOL_DATA))
271
+ monkeypatch.setattr(S3Utils, "last_modified", staticmethod(lambda b, k: None))
272
+
273
+ broker = DummyBroker()
274
+
275
+ def fake_refresh(name):
276
+ raise AssertionError("refresh_instruments should not be called")
277
+
278
+ monkeypatch.setattr(broker, "refresh_instruments", fake_refresh)
279
+
280
+ result = self.factory.set_broker_instruments(
281
+ broker_name=BrokerName.ALICEBLUE_V2, broker=broker
282
+ )
283
+
284
+ assert "BFO:OLDSYMBOL" in result.symbol_data
285
+
286
+
287
+ class TestRefreshInstruments:
288
+ def test_rebuilds_from_s3_and_updates_all_caches(self, monkeypatch, cleanup_paths):
289
+ file_name = unique_name("refresh_base")
290
+ cleanup_paths.append(f"/tmp/{file_name}.pickle")
291
+
292
+ local_csv = f"/tmp/quantplay-market-data/symbol_data/{file_name}.csv"
293
+ os.makedirs(os.path.dirname(local_csv), exist_ok=True)
294
+ with open(local_csv, "w") as f:
295
+ f.write("stale,csv\n")
296
+ cleanup_paths.append(local_csv)
297
+
298
+ df = pd.DataFrame(
299
+ [
300
+ {
301
+ "exchange": "BFO",
302
+ "broker_symbol": "SENSEX2671678400CE",
303
+ "tradingsymbol": "SENSEX2671678400CE",
304
+ "lot_size": 20,
305
+ }
306
+ ]
307
+ )
308
+
309
+ def fake_read_csv(bucket, key):
310
+ assert bucket == "quantplay-market-data"
311
+ assert key == f"symbol_data/{file_name}.csv"
312
+ return df
313
+
314
+ monkeypatch.setattr(S3Utils, "read_csv", staticmethod(fake_read_csv))
315
+
316
+ broker = DummyBroker()
317
+ broker.refresh_instruments(file_name)
318
+
319
+ assert "BFO:SENSEX2671678400CE" in broker.symbol_data
320
+ assert broker.symbol_data["BFO:SENSEX2671678400CE"]["lot_size"] == 20
321
+ assert not os.path.exists(local_csv)
322
+ assert "BFO:SENSEX2671678400CE" in PickleUtils.load_data(file_name)
323
+ assert (
324
+ InstrumentData.get_instance().load_data(file_name) == broker.symbol_data
325
+ )
326
+ assert broker.broker_symbol_map["SENSEX2671678400CE"] == "SENSEX2671678400CE"
327
+
328
+ def test_zerodha_rebuilds_from_live_instruments(self, cleanup_paths):
329
+ file_name = unique_name("zerodha_refresh")
330
+ cleanup_paths.append(f"/tmp/{file_name}.pickle")
331
+
332
+ class FakeWrapper:
333
+ def instruments(self):
334
+ return [
335
+ {
336
+ "exchange": "NFO",
337
+ "tradingsymbol": "NIFTY26JUL25000CE",
338
+ "lot_size": 75,
339
+ }
340
+ ]
341
+
342
+ broker = object.__new__(Zerodha)
343
+ broker.wrapper = FakeWrapper()
344
+ broker.refresh_instruments(file_name)
345
+
346
+ assert broker.symbol_data["NFO:NIFTY26JUL25000CE"]["lot_size"] == 75
347
+ assert "NFO:NIFTY26JUL25000CE" in PickleUtils.load_data(file_name)
@@ -0,0 +1,39 @@
1
+ import pytest
2
+
3
+ from quantplay.broker.kotak_utils.kotak_ws import (
4
+ ORDER_FEED_URL,
5
+ order_feed_url,
6
+ )
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ ("data_center", "expected"),
11
+ [
12
+ ("adc", "wss://cis.kotaksecurities.com/realtime"),
13
+ ("e21", "wss://e21.kotaksecurities.com/realtime"),
14
+ ("e22", "wss://e22.kotaksecurities.com/realtime"),
15
+ ("e41", "wss://e41.kotaksecurities.com/realtime"),
16
+ ("e43", "wss://e43.kotaksecurities.com/realtime"),
17
+ ],
18
+ )
19
+ def test_each_data_center_gets_its_own_endpoint(data_center: str, expected: str) -> None:
20
+ assert order_feed_url(data_center) == expected
21
+
22
+
23
+ def test_data_center_is_case_insensitive() -> None:
24
+ # UserBrokerAccounts stores it upper-case ("E41"); Kotak's own client
25
+ # lower-cases before comparing.
26
+ assert order_feed_url("E41") == order_feed_url("e41")
27
+
28
+
29
+ @pytest.mark.parametrize("data_center", [None, "", "zz"])
30
+ def test_unknown_or_absent_data_center_falls_back(data_center: str | None) -> None:
31
+ assert order_feed_url(data_center) == ORDER_FEED_URL
32
+
33
+
34
+ def test_non_adc_data_centers_are_not_the_default() -> None:
35
+ # The regression: e21/e22/e41/e43 silently resolved to ORDER_FEED_URL, so
36
+ # the socket connected to a data centre that holds no session for the
37
+ # account and delivered no order events.
38
+ for data_center in ("e21", "e22", "e41", "e43"):
39
+ assert order_feed_url(data_center) != ORDER_FEED_URL
File without changes
File without changes
File without changes