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.
- {quantplay-2.1.134 → quantplay-2.1.136}/PKG-INFO +1 -1
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/jainam_xts.py +27 -2
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/kotak_ws.py +27 -5
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts.py +10 -2
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/PKG-INFO +1 -1
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/SOURCES.txt +3 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/setup.py +1 -1
- quantplay-2.1.136/tests/test_deltax_positions.py +178 -0
- quantplay-2.1.136/tests/test_instrument_freshness.py +347 -0
- quantplay-2.1.136/tests/test_kotak_order_feed_url.py +39 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/README.md +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/pyproject.toml +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_utils/aliceblue_oauth.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/aliceblue_v2.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/angelone_utils/angeloneWS.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/auto_login/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/auto_login/aliceblue.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/breeze/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/breeze/breeze_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/broker_factory.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/dhan.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/fa_noren.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/finvasia_utils/finvasia_oauth.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/five_paisa.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/flattrade.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/flattrade_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/ft_utils/ft_noren.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/generics/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/generics/broker.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/icici_direct.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/iifl_xts.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kite_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/kotak_utils/kotak_ws_lib.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/motilal.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/noren.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/shoonya.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/uplink/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/uplink/uplink_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/upstox.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/Connect.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/Exception.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/InteractiveSocketClient.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/ConnectV2.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/InteractiveSocketClientV2.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/zerodha.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/core/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/core/strategy.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/exception/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/exception/exceptions.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/indicator/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/indicator/iv.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/broker.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/broker_response.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/generics.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/instrument_data.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/model/order_event.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/py.typed +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/iv_spike.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/strategy/obuy.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/caching.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/constant.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/exchange.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/number_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/pickle_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/redaction.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/utils/selenium_utils.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/aws/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/aws/s3.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay/wrapper/redis.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/dependency_links.txt +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/requires.txt +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/quantplay.egg-info/top_level.txt +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/setup.cfg +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/conftest.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_deltax_root_url.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_jainam_xts.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/test_log_redaction.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/aws/__init__.py +0 -0
- {quantplay-2.1.134 → quantplay-2.1.136}/tests/wrapper/aws/s3_test.py +0 -0
|
@@ -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=
|
|
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 =
|
|
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
|
|
427
|
-
|
|
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
|
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils/InteractiveSocketClient.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quantplay-2.1.134 → quantplay-2.1.136}/quantplay/broker/xts_utils_v2/InteractiveSocketClientV2.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|