kkunal 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- choice_api/__init__.py +22 -0
- choice_api/client.py +190 -0
- choice_api/funds.py +69 -0
- choice_api/historical.py +87 -0
- choice_api/market.py +22 -0
- choice_api/orders.py +93 -0
- choice_api/portfolio.py +43 -0
- choice_api/scrip_master.py +105 -0
- choice_api/websockets_feed.py +106 -0
- choice_api/websockets_interactive.py +94 -0
- kkunal-1.0.0.dist-info/METADATA +410 -0
- kkunal-1.0.0.dist-info/RECORD +15 -0
- kkunal-1.0.0.dist-info/WHEEL +5 -0
- kkunal-1.0.0.dist-info/licenses/LICENSE +21 -0
- kkunal-1.0.0.dist-info/top_level.txt +1 -0
choice_api/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import ChoiceClient
|
|
2
|
+
from .orders import OrdersAPI
|
|
3
|
+
from .portfolio import PortfolioAPI
|
|
4
|
+
from .funds import FundsAPI
|
|
5
|
+
from .market import MarketAPI
|
|
6
|
+
from .historical import HistoricalAPI
|
|
7
|
+
from .scrip_master import ScripMaster
|
|
8
|
+
from .websockets_interactive import InteractiveSocketClient
|
|
9
|
+
from .websockets_feed import PriceFeedSocketClient
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ChoiceClient",
|
|
14
|
+
"OrdersAPI",
|
|
15
|
+
"PortfolioAPI",
|
|
16
|
+
"FundsAPI",
|
|
17
|
+
"MarketAPI",
|
|
18
|
+
"HistoricalAPI",
|
|
19
|
+
"ScripMaster",
|
|
20
|
+
"InteractiveSocketClient",
|
|
21
|
+
"PriceFeedSocketClient"
|
|
22
|
+
]
|
choice_api/client.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import datetime
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Util.Padding import pad
|
|
9
|
+
|
|
10
|
+
from .orders import OrdersAPI
|
|
11
|
+
from .portfolio import PortfolioAPI
|
|
12
|
+
from .funds import FundsAPI
|
|
13
|
+
from .market import MarketAPI
|
|
14
|
+
from .historical import HistoricalAPI
|
|
15
|
+
from .scrip_master import ScripMaster
|
|
16
|
+
|
|
17
|
+
class ChoiceClient:
|
|
18
|
+
"""
|
|
19
|
+
Main client for interacting with the Choice API.
|
|
20
|
+
Handles authentication, session management, and provides access to other API modules.
|
|
21
|
+
"""
|
|
22
|
+
def __init__(self, vendor_id: str, vendor_key: str, api_key: str, aes_key: str, aes_iv: str, base_url: str = "https://finxomne.choiceindia.com"):
|
|
23
|
+
self.vendor_id = vendor_id
|
|
24
|
+
self.vendor_key = vendor_key
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.aes_key = aes_key
|
|
27
|
+
self.aes_iv = aes_iv
|
|
28
|
+
self.base_url = base_url.rstrip('/')
|
|
29
|
+
|
|
30
|
+
self.session_id: Optional[str] = None
|
|
31
|
+
self.access_token: Optional[str] = None
|
|
32
|
+
self.bcast_ip: Optional[str] = None
|
|
33
|
+
self.bcast_port: Optional[int] = None
|
|
34
|
+
|
|
35
|
+
# Initialize sub-modules
|
|
36
|
+
self.orders = OrdersAPI(self)
|
|
37
|
+
self.portfolio = PortfolioAPI(self)
|
|
38
|
+
self.funds = FundsAPI(self)
|
|
39
|
+
self.market = MarketAPI(self)
|
|
40
|
+
self.historical = HistoricalAPI(self)
|
|
41
|
+
self.scrip_master = ScripMaster()
|
|
42
|
+
|
|
43
|
+
def _get_encrypted_mobile(self, mobile_no: str) -> str:
|
|
44
|
+
"""Encrypts the mobile number using AES CBC with the provided keys."""
|
|
45
|
+
aes_key_bytes = self.aes_key.encode('utf-8')
|
|
46
|
+
aes_iv_bytes = self.aes_iv.encode('utf-8')
|
|
47
|
+
cipher = AES.new(aes_key_bytes, AES.MODE_CBC, aes_iv_bytes)
|
|
48
|
+
padded = pad(mobile_no.encode('utf-8'), AES.block_size)
|
|
49
|
+
encrypted = cipher.encrypt(padded)
|
|
50
|
+
return base64.b64encode(encrypted).decode('utf-8')
|
|
51
|
+
|
|
52
|
+
def get_headers(self, include_auth: bool = True) -> Dict[str, str]:
|
|
53
|
+
"""Constructs headers required for API requests."""
|
|
54
|
+
headers = {
|
|
55
|
+
"VendorId": self.vendor_id,
|
|
56
|
+
"VendorKey": self.vendor_key,
|
|
57
|
+
"Bearer": self.api_key,
|
|
58
|
+
"Content-Type": "application/json"
|
|
59
|
+
}
|
|
60
|
+
if include_auth and self.session_id:
|
|
61
|
+
headers["Authorization"] = f"SessionId {self.session_id}"
|
|
62
|
+
return headers
|
|
63
|
+
|
|
64
|
+
def request(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None, require_auth: bool = True) -> Dict[str, Any]:
|
|
65
|
+
"""Base method for making HTTP requests to the API."""
|
|
66
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
67
|
+
headers = self.get_headers(include_auth=require_auth)
|
|
68
|
+
|
|
69
|
+
response = requests.request(method, url, headers=headers, json=data)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
return response.json()
|
|
74
|
+
except requests.exceptions.HTTPError as e:
|
|
75
|
+
raise Exception(f"HTTP Request failed: {e}\nResponse: {response.text}")
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
raise Exception(f"Failed to decode JSON response: {response.text}")
|
|
78
|
+
|
|
79
|
+
def login(self, mobile_no: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Executes the full TOTP login flow to obtain a SessionId.
|
|
82
|
+
Flow:
|
|
83
|
+
1. LoginTOTP
|
|
84
|
+
2. GetClientLoginTOTP (Retrieve OTP)
|
|
85
|
+
3. ValidateTOTP (Submit OTP to get SessionId)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The acquired SessionId.
|
|
89
|
+
"""
|
|
90
|
+
enc_mobile = self._get_encrypted_mobile(mobile_no)
|
|
91
|
+
|
|
92
|
+
# Step 1: Request TOTP
|
|
93
|
+
resp1 = self.request("POST", "api/OpenAPIV1/LoginTOTP", {"MobileNo": enc_mobile}, require_auth=False)
|
|
94
|
+
if resp1.get("Status") != "Success":
|
|
95
|
+
raise Exception(f"LoginTOTP failed: {resp1}")
|
|
96
|
+
|
|
97
|
+
# Step 2: Get OTP generated
|
|
98
|
+
resp2 = self.request("POST", "api/OpenAPIV1/GetClientLoginTOTP", {"MobileNo": enc_mobile}, require_auth=False)
|
|
99
|
+
if resp2.get("Status") != "Success":
|
|
100
|
+
raise Exception(f"GetClientLoginTOTP failed: {resp2}")
|
|
101
|
+
|
|
102
|
+
otp = resp2.get("Response")
|
|
103
|
+
if not otp:
|
|
104
|
+
raise Exception(f"OTP not found in response: {resp2}")
|
|
105
|
+
|
|
106
|
+
# Step 3: Validate TOTP
|
|
107
|
+
resp3 = self.request("POST", "api/OpenAPIV1/ValidateTOTP", {"MobileNo": enc_mobile, "OTP": str(otp)}, require_auth=False)
|
|
108
|
+
if resp3.get("Status") != "Success":
|
|
109
|
+
raise Exception(f"ValidateTOTP failed: {resp3}")
|
|
110
|
+
|
|
111
|
+
# Extract Session ID and other details
|
|
112
|
+
response_data = resp3.get("Response", {})
|
|
113
|
+
if isinstance(response_data, str):
|
|
114
|
+
self.session_id = response_data
|
|
115
|
+
elif isinstance(response_data, dict):
|
|
116
|
+
self.session_id = response_data.get("SessionId") or response_data.get("session_id")
|
|
117
|
+
self.access_token = response_data.get("AccessToken")
|
|
118
|
+
self.bcast_ip = response_data.get("OdinBcastIP")
|
|
119
|
+
|
|
120
|
+
bcast_port_raw = response_data.get("OdinBcastPort")
|
|
121
|
+
if bcast_port_raw:
|
|
122
|
+
try:
|
|
123
|
+
self.bcast_port = int(bcast_port_raw)
|
|
124
|
+
except ValueError:
|
|
125
|
+
self.bcast_port = None
|
|
126
|
+
|
|
127
|
+
# If AccessToken isn't present, fallback to the Bearer API_KEY since
|
|
128
|
+
# some versions of the API allow the same JWT to be reused for the WS.
|
|
129
|
+
if not self.access_token:
|
|
130
|
+
self.access_token = self.api_key
|
|
131
|
+
|
|
132
|
+
if not self.session_id:
|
|
133
|
+
raise Exception("Failed to extract SessionId from ValidateTOTP response")
|
|
134
|
+
|
|
135
|
+
# Automatically fetch the daily scrip master
|
|
136
|
+
try:
|
|
137
|
+
self.scrip_master.fetch()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print(f"Warning: Failed to fetch scrip master during login: {e}")
|
|
140
|
+
|
|
141
|
+
return self.session_id
|
|
142
|
+
|
|
143
|
+
def save_session(self, filepath: str) -> bool:
|
|
144
|
+
"""Saves the current active session to a file."""
|
|
145
|
+
if not self.session_id:
|
|
146
|
+
return False
|
|
147
|
+
try:
|
|
148
|
+
with open(filepath, 'w') as f:
|
|
149
|
+
json.dump({
|
|
150
|
+
"date": datetime.date.today().isoformat(),
|
|
151
|
+
"session_id": self.session_id,
|
|
152
|
+
"access_token": self.access_token,
|
|
153
|
+
"bcast_ip": self.bcast_ip,
|
|
154
|
+
"bcast_port": self.bcast_port
|
|
155
|
+
}, f)
|
|
156
|
+
return True
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print(f"Warning: Failed to save session file: {e}")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def load_session(self, filepath: str) -> bool:
|
|
162
|
+
"""Attempts to load a valid session for today from a file."""
|
|
163
|
+
if not os.path.exists(filepath):
|
|
164
|
+
return False
|
|
165
|
+
try:
|
|
166
|
+
with open(filepath, 'r') as f:
|
|
167
|
+
data = json.load(f)
|
|
168
|
+
if data.get("date") == datetime.date.today().isoformat():
|
|
169
|
+
self.session_id = data.get("session_id")
|
|
170
|
+
self.access_token = data.get("access_token")
|
|
171
|
+
self.bcast_ip = data.get("bcast_ip")
|
|
172
|
+
self.bcast_port = data.get("bcast_port")
|
|
173
|
+
if not self.access_token:
|
|
174
|
+
self.access_token = self.api_key
|
|
175
|
+
|
|
176
|
+
# Pre-load scrip master
|
|
177
|
+
try:
|
|
178
|
+
self.scrip_master.fetch()
|
|
179
|
+
except:
|
|
180
|
+
pass
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
except Exception:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
def logoff(self) -> Dict[str, Any]:
|
|
187
|
+
"""Logs out the current session."""
|
|
188
|
+
response = self.request("GET", "api/OpenAPI/LogOff")
|
|
189
|
+
self.session_id = None
|
|
190
|
+
return response
|
choice_api/funds.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class FundsAPI:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def get_funds_view(self) -> Dict[str, Any]:
|
|
8
|
+
"""Retrieves funds summary."""
|
|
9
|
+
return self.client.request("GET", "api/OpenAPI/FundsView")
|
|
10
|
+
|
|
11
|
+
def get_funds_view_new(self) -> Dict[str, Any]:
|
|
12
|
+
"""Retrieves the new format funds summary."""
|
|
13
|
+
return self.client.request("GET", "api/OpenAPI/FundsViewNew")
|
|
14
|
+
|
|
15
|
+
def process_payout(self, amount: float, bank_acc_no: str, product_type: int = 0) -> Dict[str, Any]:
|
|
16
|
+
"""Initiates a fund payout request."""
|
|
17
|
+
payload = {
|
|
18
|
+
"Amount": amount,
|
|
19
|
+
"ProductType": product_type,
|
|
20
|
+
"BankAccNo": bank_acc_no
|
|
21
|
+
}
|
|
22
|
+
return self.client.request("POST", "api/OpenAPI/ProcessPayout", payload)
|
|
23
|
+
|
|
24
|
+
def payment_via_netbanking(self, amount: float, bank_acc_no: str, bank_ifsc_code: str, return_url: str,
|
|
25
|
+
segment_id: int, product_type: int = 0) -> Dict[str, Any]:
|
|
26
|
+
"""Initiates payment via net banking."""
|
|
27
|
+
payload = {
|
|
28
|
+
"Amount": amount,
|
|
29
|
+
"BankAccNo": bank_acc_no,
|
|
30
|
+
"BankIFSCCode": bank_ifsc_code,
|
|
31
|
+
"ReturnUrl": return_url,
|
|
32
|
+
"ProductType": product_type,
|
|
33
|
+
"SegmentId": segment_id
|
|
34
|
+
}
|
|
35
|
+
return self.client.request("POST", "api/OpenAPI/PaymentViaNB", payload)
|
|
36
|
+
|
|
37
|
+
def payment_via_hdfc_upi(self, amount: float, bank_acc_no: str, user_vpa: str,
|
|
38
|
+
segment_id: int, product_type: int = 0) -> Dict[str, Any]:
|
|
39
|
+
"""Initiates payment via HDFC UPI."""
|
|
40
|
+
payload = {
|
|
41
|
+
"Amount": amount,
|
|
42
|
+
"BankAccNo": bank_acc_no,
|
|
43
|
+
"ProductType": product_type,
|
|
44
|
+
"SegmentId": segment_id,
|
|
45
|
+
"UserVPA": user_vpa
|
|
46
|
+
}
|
|
47
|
+
return self.client.request("POST", "api/OpenAPI/PaymentViaHDFCUPI", payload)
|
|
48
|
+
|
|
49
|
+
def check_vpa(self, user_vpa: str) -> Dict[str, Any]:
|
|
50
|
+
"""Checks if a VPA is valid."""
|
|
51
|
+
return self.client.request("POST", "api/OpenAPI/CheckVPA", {"UserVPA": user_vpa})
|
|
52
|
+
|
|
53
|
+
def payment_via_razorpay(self, amount: float, bank_acc_no: str, bank_ifsc_code: str, upi_id: str,
|
|
54
|
+
segment_id: int, payment_type: int = 0, product_type: int = 0) -> Dict[str, Any]:
|
|
55
|
+
"""Initiates payment via RazorPay."""
|
|
56
|
+
payload = {
|
|
57
|
+
"Amount": amount,
|
|
58
|
+
"BankAccNo": bank_acc_no,
|
|
59
|
+
"BankIFSCCode": bank_ifsc_code,
|
|
60
|
+
"ProductType": product_type,
|
|
61
|
+
"PaymentType": payment_type,
|
|
62
|
+
"UPIId": upi_id,
|
|
63
|
+
"SegmentId": segment_id
|
|
64
|
+
}
|
|
65
|
+
return self.client.request("POST", "api/OpenAPI/PaymentViaRazorPay", payload)
|
|
66
|
+
|
|
67
|
+
def payment_ack_response(self, transaction_id: str) -> Dict[str, Any]:
|
|
68
|
+
"""Acknowledges a payment response."""
|
|
69
|
+
return self.client.request("POST", "api/OpenAPI/PaymentAckResponse", {"TransactionId": transaction_id})
|
choice_api/historical.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Dict, Any, Union, TYPE_CHECKING
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
class HistoricalAPI:
|
|
8
|
+
def __init__(self, client):
|
|
9
|
+
self.client = client
|
|
10
|
+
|
|
11
|
+
def _parse_date(self, date_val: Union[str, int]) -> int:
|
|
12
|
+
if isinstance(date_val, int):
|
|
13
|
+
return date_val
|
|
14
|
+
epoch_1980 = datetime(1980, 1, 1)
|
|
15
|
+
try:
|
|
16
|
+
if " " in date_val:
|
|
17
|
+
dt = datetime.strptime(date_val, "%Y-%m-%d %H:%M:%S")
|
|
18
|
+
else:
|
|
19
|
+
dt = datetime.strptime(date_val, "%Y-%m-%d")
|
|
20
|
+
return int((dt - epoch_1980).total_seconds())
|
|
21
|
+
except Exception:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
def get_historical_data(self, segment_id: int, token: int, from_date: Union[str, int], to_date: Union[str, int], resolution: str) -> "pd.DataFrame":
|
|
25
|
+
"""
|
|
26
|
+
Retrieves historical chart data (e.g., OHLCV) as a Pandas DataFrame.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
segment_id: Exchange Segment ID (e.g., 1 for NSE Cash).
|
|
30
|
+
token: Instrument token.
|
|
31
|
+
from_date: Start date (format 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' or seconds from 1980).
|
|
32
|
+
to_date: End date (format 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' or seconds from 1980).
|
|
33
|
+
resolution: Timeframe (e.g., '1', '5', 'D').
|
|
34
|
+
"""
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
payload = {
|
|
38
|
+
"SegmentId": segment_id,
|
|
39
|
+
"Token": token,
|
|
40
|
+
"FromDate": self._parse_date(from_date),
|
|
41
|
+
"ToDate": self._parse_date(to_date),
|
|
42
|
+
"Interval": resolution
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resp = self.client.request("POST", "api/OpenGraph/ChartData", payload)
|
|
46
|
+
|
|
47
|
+
if resp.get("Status") == "Success":
|
|
48
|
+
data = resp.get("Response", {})
|
|
49
|
+
history = data.get("lstChartHistory", [])
|
|
50
|
+
divisor = data.get("PriceDivisor", 1)
|
|
51
|
+
|
|
52
|
+
if not history:
|
|
53
|
+
return pd.DataFrame()
|
|
54
|
+
|
|
55
|
+
parsed_data = []
|
|
56
|
+
for row in history:
|
|
57
|
+
parts = row.split(',')
|
|
58
|
+
# Usually: Time, Open, High, Low, Close, Volume, OI
|
|
59
|
+
if len(parts) >= 7:
|
|
60
|
+
parsed_data.append([
|
|
61
|
+
int(parts[0]),
|
|
62
|
+
float(parts[1]) / divisor if divisor else float(parts[1]),
|
|
63
|
+
float(parts[2]) / divisor if divisor else float(parts[2]),
|
|
64
|
+
float(parts[3]) / divisor if divisor else float(parts[3]),
|
|
65
|
+
float(parts[4]) / divisor if divisor else float(parts[4]),
|
|
66
|
+
int(parts[5]),
|
|
67
|
+
int(parts[6])
|
|
68
|
+
])
|
|
69
|
+
else:
|
|
70
|
+
parsed_row = [float(p) / divisor if idx in (1,2,3,4) else float(p) for idx, p in enumerate(parts)]
|
|
71
|
+
parsed_data.append(parsed_row)
|
|
72
|
+
|
|
73
|
+
columns = ["Time", "Open", "High", "Low", "Close", "Volume", "OI"]
|
|
74
|
+
if parsed_data and len(parsed_data[0]) <= len(columns):
|
|
75
|
+
df = pd.DataFrame(parsed_data, columns=columns[:len(parsed_data[0])])
|
|
76
|
+
else:
|
|
77
|
+
df = pd.DataFrame(parsed_data)
|
|
78
|
+
|
|
79
|
+
if "Time" in df.columns:
|
|
80
|
+
df["Time"] = pd.to_datetime(df["Time"], unit='s', origin=pd.Timestamp('1980-01-01'))
|
|
81
|
+
|
|
82
|
+
return df
|
|
83
|
+
|
|
84
|
+
# If not successful, return empty dataframe or raise?
|
|
85
|
+
# Return empty DataFrame to maintain return type consistency,
|
|
86
|
+
# as self.client.request raises Exception for HTTP errors.
|
|
87
|
+
return pd.DataFrame()
|
choice_api/market.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
class MarketAPI:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def get_market_status(self) -> Dict[str, Any]:
|
|
8
|
+
"""Retrieves current market status across segments."""
|
|
9
|
+
return self.client.request("GET", "api/OpenAPI/MarketStatus")
|
|
10
|
+
|
|
11
|
+
def get_user_profile(self) -> Dict[str, Any]:
|
|
12
|
+
"""Retrieves user profile information."""
|
|
13
|
+
return self.client.request("GET", "api/OpenAPI/UserProfile")
|
|
14
|
+
|
|
15
|
+
def get_multiple_touchline(self, multiple_seg_token: str) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Retrieves multiple touchlines.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
multiple_seg_token: Comma-separated list of segments and tokens.
|
|
21
|
+
"""
|
|
22
|
+
return self.client.request("POST", "api/OpenAPI/MultipleTouchline", {"MultipleSegToken": multiple_seg_token})
|
choice_api/orders.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class OrdersAPI:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def place_order(self, segment_id: int, token: int, order_type: str, bs: int, qty: int,
|
|
8
|
+
price: float, trigger_price: float, validity: int, product_type: str,
|
|
9
|
+
disclosed_qty: int = 0, is_edis_req: bool = False) -> Dict[str, Any]:
|
|
10
|
+
"""Places a new order."""
|
|
11
|
+
payload = {
|
|
12
|
+
"SegmentId": segment_id,
|
|
13
|
+
"Token": token,
|
|
14
|
+
"OrderType": order_type,
|
|
15
|
+
"BS": bs,
|
|
16
|
+
"Qty": qty,
|
|
17
|
+
"DisclosedQty": disclosed_qty,
|
|
18
|
+
"Price": price,
|
|
19
|
+
"TriggerPrice": trigger_price,
|
|
20
|
+
"Validity": validity,
|
|
21
|
+
"ProductType": product_type,
|
|
22
|
+
"IsEdisReq": is_edis_req,
|
|
23
|
+
"Remarks": "API",
|
|
24
|
+
"ModeTyp": "WEBAPI",
|
|
25
|
+
"Mode": 1,
|
|
26
|
+
"DeviceId": "MAC",
|
|
27
|
+
"ClientOrderNo": 123456
|
|
28
|
+
}
|
|
29
|
+
return self.client.request("POST", "api/OpenAPI/V2/NewOrder", payload)
|
|
30
|
+
|
|
31
|
+
def modify_order(self, client_order_no: int, exchange_order_no: str, gateway_order_no: str,
|
|
32
|
+
segment_id: int, token: int, order_type: str, bs: int, qty: int, price: float,
|
|
33
|
+
trigger_price: float, validity: int, product_type: str, disclosed_qty: int = 0) -> Dict[str, Any]:
|
|
34
|
+
"""Modifies an existing order."""
|
|
35
|
+
payload = {
|
|
36
|
+
"ClientOrderNo": client_order_no,
|
|
37
|
+
"ExchangeOrderNo": exchange_order_no,
|
|
38
|
+
"GatewayOrderNo": gateway_order_no,
|
|
39
|
+
"SegmentId": segment_id,
|
|
40
|
+
"Token": token,
|
|
41
|
+
"OrderType": order_type,
|
|
42
|
+
"BS": bs,
|
|
43
|
+
"Qty": qty,
|
|
44
|
+
"DisclosedQty": disclosed_qty,
|
|
45
|
+
"Price": price,
|
|
46
|
+
"TriggerPrice": trigger_price,
|
|
47
|
+
"Validity": validity,
|
|
48
|
+
"ProductType": product_type
|
|
49
|
+
}
|
|
50
|
+
return self.client.request("POST", "api/OpenAPI/ModifyOrder", payload)
|
|
51
|
+
|
|
52
|
+
def cancel_order(self, client_order_no: int, exchange_order_no: str, gateway_order_no: str,
|
|
53
|
+
segment_id: int, token: int, order_type: str, bs: int, qty: int, price: float,
|
|
54
|
+
trigger_price: float, validity: int, product_type: str, exchange_order_time: str = "",
|
|
55
|
+
disclosed_qty: int = 0) -> Dict[str, Any]:
|
|
56
|
+
"""Cancels an order."""
|
|
57
|
+
payload = {
|
|
58
|
+
"ExchangeOrderTime": exchange_order_time,
|
|
59
|
+
"ClientOrderNo": client_order_no,
|
|
60
|
+
"ExchangeOrderNo": exchange_order_no,
|
|
61
|
+
"GatewayOrderNo": gateway_order_no,
|
|
62
|
+
"SegmentId": segment_id,
|
|
63
|
+
"Token": token,
|
|
64
|
+
"OrderType": order_type,
|
|
65
|
+
"BS": bs,
|
|
66
|
+
"Qty": qty,
|
|
67
|
+
"DisclosedQty": disclosed_qty,
|
|
68
|
+
"Price": price,
|
|
69
|
+
"TriggerPrice": trigger_price,
|
|
70
|
+
"Validity": validity,
|
|
71
|
+
"ProductType": product_type
|
|
72
|
+
}
|
|
73
|
+
return self.client.request("POST", "api/OpenAPI/CancelOrder", payload)
|
|
74
|
+
|
|
75
|
+
def get_order_book(self) -> Dict[str, Any]:
|
|
76
|
+
"""Retrieves the full order book."""
|
|
77
|
+
return self.client.request("GET", "api/OpenAPI/OrderBook")
|
|
78
|
+
|
|
79
|
+
def get_order_book_v2(self) -> Dict[str, Any]:
|
|
80
|
+
"""Retrieves order book version 2."""
|
|
81
|
+
return self.client.request("GET", "api/OpenAPI/OrderBookV2")
|
|
82
|
+
|
|
83
|
+
def get_order_by_no(self, order_no: int) -> Dict[str, Any]:
|
|
84
|
+
"""Retrieves a specific order by its order number."""
|
|
85
|
+
return self.client.request("GET", f"api/OpenAPI/OrderBookByOrderNo/{order_no}")
|
|
86
|
+
|
|
87
|
+
def get_trade_book(self) -> Dict[str, Any]:
|
|
88
|
+
"""Retrieves the trade book."""
|
|
89
|
+
return self.client.request("GET", "api/OpenAPI/TradeBook")
|
|
90
|
+
|
|
91
|
+
def get_order_messages(self, req_id: str) -> Dict[str, Any]:
|
|
92
|
+
"""Retrieves order messages."""
|
|
93
|
+
return self.client.request("POST", "api/OpenAPI/OrderMessages", {"ReqId": req_id})
|
choice_api/portfolio.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
|
|
3
|
+
class PortfolioAPI:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def get_holdings(self) -> Dict[str, Any]:
|
|
8
|
+
"""Retrieves user's holdings."""
|
|
9
|
+
return self.client.request("GET", "api/OpenAPI/Holdings")
|
|
10
|
+
|
|
11
|
+
def get_net_position(self) -> Dict[str, Any]:
|
|
12
|
+
"""Retrieves the net position of the user."""
|
|
13
|
+
return self.client.request("GET", "api/OpenAPI/NetPosition")
|
|
14
|
+
|
|
15
|
+
def position_conversion(self, segment_id: int, token: int, client_order_no: int, buy_sell: int,
|
|
16
|
+
quantity: int, product_type: str, source_product_type: str, is_edis_req: bool = True) -> Dict[str, Any]:
|
|
17
|
+
"""Converts an open position from one product type to another."""
|
|
18
|
+
payload = {
|
|
19
|
+
"SegmentId": segment_id,
|
|
20
|
+
"Token": token,
|
|
21
|
+
"ClientOrderNo": client_order_no,
|
|
22
|
+
"BuySell": buy_sell,
|
|
23
|
+
"Quantity": quantity,
|
|
24
|
+
"ProductType": product_type,
|
|
25
|
+
"SourceProductType": source_product_type,
|
|
26
|
+
"IsEDISReq": is_edis_req
|
|
27
|
+
}
|
|
28
|
+
return self.client.request("POST", "api/OpenAPI/PositionConversion", payload)
|
|
29
|
+
|
|
30
|
+
def verify_dis(self, boid: str, ctr_boid: str, ex_id: int, channel: int, edis_stocks: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
31
|
+
"""Verifies eDIS."""
|
|
32
|
+
payload = {
|
|
33
|
+
"BOID": boid,
|
|
34
|
+
"CtrBOID": ctr_boid,
|
|
35
|
+
"ExId": ex_id,
|
|
36
|
+
"channel": channel,
|
|
37
|
+
"eDISStocks": edis_stocks
|
|
38
|
+
}
|
|
39
|
+
return self.client.request("POST", "api/OpenAPI/VerifyDIS", payload)
|
|
40
|
+
|
|
41
|
+
def get_dis_status(self) -> Dict[str, Any]:
|
|
42
|
+
"""Retrieves the DIS status."""
|
|
43
|
+
return self.client.request("POST", "api/OpenAPI/GetDISStatus", {})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import urllib.request
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class ScripMaster:
|
|
9
|
+
"""
|
|
10
|
+
Manages downloading and parsing the daily Scrip Master from Choice API.
|
|
11
|
+
Provides fast lookups for Tokens, Lot Sizes, and Symbols.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.symbol_to_token = {}
|
|
15
|
+
self.token_to_details = {}
|
|
16
|
+
self.is_loaded = False
|
|
17
|
+
|
|
18
|
+
def fetch(self):
|
|
19
|
+
"""
|
|
20
|
+
Fetches the Scrip Master CSV for the current date.
|
|
21
|
+
If today's file is unavailable (e.g., weekends/holidays early morning),
|
|
22
|
+
it falls back to the previous day.
|
|
23
|
+
"""
|
|
24
|
+
logger.info("Fetching daily scrip master...")
|
|
25
|
+
|
|
26
|
+
# Try today, then yesterday, then the day before
|
|
27
|
+
for days_back in range(3):
|
|
28
|
+
target_date = datetime.now() - timedelta(days=days_back)
|
|
29
|
+
date_str = target_date.strftime("%d%b%Y")
|
|
30
|
+
url = f"https://scripmaster.choiceindia.com/scripmaster/SCRIP_MASTER_{date_str}.csv"
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
|
34
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
35
|
+
# Decode the response and parse as CSV
|
|
36
|
+
lines = (line.decode('utf-8', errors='ignore') for line in response)
|
|
37
|
+
reader = csv.DictReader(lines)
|
|
38
|
+
|
|
39
|
+
count = 0
|
|
40
|
+
for row in reader:
|
|
41
|
+
token = row.get('Token', '').strip()
|
|
42
|
+
if not token:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
symbol = row.get('Symbol', '').strip()
|
|
46
|
+
sec_desc = row.get('SecDesc', '').strip()
|
|
47
|
+
|
|
48
|
+
# Store complete details
|
|
49
|
+
self.token_to_details[token] = row
|
|
50
|
+
|
|
51
|
+
# Map symbol/sec_desc to token for reverse lookup
|
|
52
|
+
if sec_desc:
|
|
53
|
+
self.symbol_to_token[sec_desc] = token
|
|
54
|
+
if symbol and symbol not in self.symbol_to_token:
|
|
55
|
+
self.symbol_to_token[symbol] = token
|
|
56
|
+
|
|
57
|
+
count += 1
|
|
58
|
+
|
|
59
|
+
self.is_loaded = True
|
|
60
|
+
logger.info(f"Successfully loaded {count} symbols from {date_str} master.")
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
except urllib.error.HTTPError as e:
|
|
64
|
+
if e.code == 404:
|
|
65
|
+
logger.debug(f"Scrip master not found for {date_str}, trying previous day.")
|
|
66
|
+
continue
|
|
67
|
+
else:
|
|
68
|
+
logger.error(f"HTTP Error fetching scrip master: {e}")
|
|
69
|
+
break
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Failed to fetch scrip master: {e}")
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
logger.warning("Could not download Scrip Master.")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def get_token(self, symbol_or_desc: str, exchange: str = None) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Returns the Token for a given Symbol or SecDesc.
|
|
80
|
+
If exchange is provided (e.g., 'NSE' or 'BSE'), it will ensure the exact match for that exchange.
|
|
81
|
+
"""
|
|
82
|
+
if exchange:
|
|
83
|
+
exchange_upper = exchange.upper().strip()
|
|
84
|
+
for token, details in self.token_to_details.items():
|
|
85
|
+
d_symbol = details.get('Symbol', '').strip()
|
|
86
|
+
d_sec_desc = details.get('SecDesc', '').strip()
|
|
87
|
+
d_exch = details.get('Exchange', '').strip().upper()
|
|
88
|
+
if (d_symbol == symbol_or_desc or d_sec_desc == symbol_or_desc) and d_exch == exchange_upper:
|
|
89
|
+
return token
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
return self.symbol_to_token.get(symbol_or_desc)
|
|
93
|
+
|
|
94
|
+
def get_details(self, token: str) -> dict:
|
|
95
|
+
"""Returns all CSV row details for a given Token."""
|
|
96
|
+
return self.token_to_details.get(str(token), {})
|
|
97
|
+
|
|
98
|
+
def get_lot_size(self, token: str) -> int:
|
|
99
|
+
"""Helper to quickly get the lot size for a token."""
|
|
100
|
+
details = self.get_details(token)
|
|
101
|
+
lot_size_str = details.get("MarketLot", "1")
|
|
102
|
+
try:
|
|
103
|
+
return int(lot_size_str)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return 1
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import zlib
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class PriceFeedSocketClient:
|
|
9
|
+
"""
|
|
10
|
+
Live Price Feed Socket using FIX3.0 delimited ASCII formats and Zlib compression.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, host: str, port: int, user_id: str):
|
|
13
|
+
self.host = host
|
|
14
|
+
self.port = port
|
|
15
|
+
self.user_id = user_id
|
|
16
|
+
self.reader = None
|
|
17
|
+
self.writer = None
|
|
18
|
+
self._connected = False
|
|
19
|
+
self._callbacks = []
|
|
20
|
+
|
|
21
|
+
def on_message(self, callback: Callable):
|
|
22
|
+
"""Registers a callback for parsed feed messages."""
|
|
23
|
+
self._callbacks.append(callback)
|
|
24
|
+
|
|
25
|
+
async def connect(self):
|
|
26
|
+
"""Connects to the Web Feed Handler via TCP Socket."""
|
|
27
|
+
try:
|
|
28
|
+
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
|
29
|
+
self._connected = True
|
|
30
|
+
logger.info(f"Connected to Price Feed Socket at {self.host}:{self.port}")
|
|
31
|
+
|
|
32
|
+
# Send Logon Request
|
|
33
|
+
await self.send_login()
|
|
34
|
+
await self._listen()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Failed to connect to Price Feed Socket: {e}")
|
|
37
|
+
|
|
38
|
+
async def disconnect(self):
|
|
39
|
+
"""Disconnects the socket."""
|
|
40
|
+
self._connected = False
|
|
41
|
+
if self.writer:
|
|
42
|
+
self.writer.close()
|
|
43
|
+
await self.writer.wait_closed()
|
|
44
|
+
logger.info("Disconnected from Price Feed Socket.")
|
|
45
|
+
|
|
46
|
+
async def send_login(self):
|
|
47
|
+
"""Sends the Logon Request (101)."""
|
|
48
|
+
# Format: 63=FIX3.0|64=101|65=66|66=2022-05-04 133022|400=11|67=USERID|68=|
|
|
49
|
+
login_msg = f"63=FIX3.0|64=101|65=66|66=2023-03-06 113929|67={self.user_id}|68=|400=11"
|
|
50
|
+
self._send_raw(login_msg)
|
|
51
|
+
|
|
52
|
+
def subscribe_touchline(self, session_id: str, segment_id: int, token: int):
|
|
53
|
+
"""Subscribes to Touchline (Market Data)."""
|
|
54
|
+
# Message code 206
|
|
55
|
+
msg = f"63=FIX3.0|64=206|65=107|66=2023-02-11 19:02:31|1=1$7={token}|230=1|4={session_id}|"
|
|
56
|
+
self._send_raw(msg)
|
|
57
|
+
|
|
58
|
+
def subscribe_best_five(self, session_id: str, segment_id: int, token: int):
|
|
59
|
+
"""Subscribes to Best Five (Depth Data)."""
|
|
60
|
+
# Message code 127
|
|
61
|
+
msg = f"63=FIX3.0|64=127|65=84|66=2023-03-06 19:02:31|1=1|7={token}|230=1|4={session_id}"
|
|
62
|
+
self._send_raw(msg)
|
|
63
|
+
|
|
64
|
+
def _send_raw(self, msg: str):
|
|
65
|
+
"""Sends a raw uncompressed message (first byte = '2', next 5 bytes = length)."""
|
|
66
|
+
length_str = str(len(msg)).zfill(5)
|
|
67
|
+
packet = f"2{length_str}{msg}".encode('utf-8')
|
|
68
|
+
if self.writer:
|
|
69
|
+
self.writer.write(packet)
|
|
70
|
+
logger.debug(f"Sent: {msg}")
|
|
71
|
+
|
|
72
|
+
async def _listen(self):
|
|
73
|
+
"""Listens and decompresses incoming messages."""
|
|
74
|
+
try:
|
|
75
|
+
while self._connected and self.reader:
|
|
76
|
+
# Read 6 byte header: 1 byte type, 5 bytes length
|
|
77
|
+
header = await self.reader.readexactly(6)
|
|
78
|
+
msg_type = chr(header[0])
|
|
79
|
+
msg_len = int(header[1:6].decode('ascii'))
|
|
80
|
+
|
|
81
|
+
payload = await self.reader.readexactly(msg_len)
|
|
82
|
+
|
|
83
|
+
if msg_type == '5': # Compressed
|
|
84
|
+
try:
|
|
85
|
+
decompressed = zlib.decompress(payload)
|
|
86
|
+
self._process_message(decompressed.decode('ascii', errors='ignore'))
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Zlib decompression failed: {e}")
|
|
89
|
+
elif msg_type == '2': # Uncompressed
|
|
90
|
+
self._process_message(payload.decode('ascii', errors='ignore'))
|
|
91
|
+
else:
|
|
92
|
+
logger.warning(f"Unknown message format indicator: {msg_type}")
|
|
93
|
+
except asyncio.IncompleteReadError:
|
|
94
|
+
logger.info("Connection closed by server.")
|
|
95
|
+
self._connected = False
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Feed listen error: {e}")
|
|
98
|
+
|
|
99
|
+
def _process_message(self, raw_str: str):
|
|
100
|
+
"""Parses the FIX3.0 pipe delimited string."""
|
|
101
|
+
# Simple split by pipe
|
|
102
|
+
for cb in self._callbacks:
|
|
103
|
+
try:
|
|
104
|
+
cb(raw_str)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Feed callback error: {e}")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import websockets
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class InteractiveSocketClient:
|
|
10
|
+
"""
|
|
11
|
+
FINX Interactive Socket for receiving order status, trade confirmations, and market events.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, token: str, host: str = "wss://finxsocket.choiceindia.com/ws/"):
|
|
14
|
+
self.url = f"{host}?token={token}"
|
|
15
|
+
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
16
|
+
self._connected = False
|
|
17
|
+
self._callbacks = {
|
|
18
|
+
"MKT_STAT": [],
|
|
19
|
+
"ORD_NRML": [],
|
|
20
|
+
"TRD_MSG": [],
|
|
21
|
+
"error": []
|
|
22
|
+
}
|
|
23
|
+
self._keepalive_task = None
|
|
24
|
+
|
|
25
|
+
def on(self, event_type: str, callback: Callable):
|
|
26
|
+
"""Registers a callback for a specific MessageType (MKT_STAT, ORD_NRML, TRD_MSG)."""
|
|
27
|
+
if event_type in self._callbacks:
|
|
28
|
+
self._callbacks[event_type].append(callback)
|
|
29
|
+
|
|
30
|
+
async def connect(self):
|
|
31
|
+
"""Connects to the interactive socket."""
|
|
32
|
+
try:
|
|
33
|
+
self.ws = await websockets.connect(self.url)
|
|
34
|
+
self._connected = True
|
|
35
|
+
logger.info("Connected to FINX Interactive Socket.")
|
|
36
|
+
self._keepalive_task = asyncio.create_task(self._keepalive())
|
|
37
|
+
await self._listen()
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error(f"Failed to connect to Interactive Socket: {e}")
|
|
40
|
+
self._trigger_callbacks("error", e)
|
|
41
|
+
|
|
42
|
+
async def disconnect(self):
|
|
43
|
+
"""Disconnects the socket."""
|
|
44
|
+
self._connected = False
|
|
45
|
+
if self._keepalive_task:
|
|
46
|
+
self._keepalive_task.cancel()
|
|
47
|
+
if self.ws:
|
|
48
|
+
await self.ws.close()
|
|
49
|
+
logger.info("Disconnected from Interactive Socket.")
|
|
50
|
+
|
|
51
|
+
async def _keepalive(self):
|
|
52
|
+
"""Sends '2' every 25 seconds to keep the connection alive."""
|
|
53
|
+
try:
|
|
54
|
+
while self._connected and self.ws:
|
|
55
|
+
await asyncio.sleep(25)
|
|
56
|
+
await self.ws.send("2")
|
|
57
|
+
logger.debug("Sent heartbeat (2)")
|
|
58
|
+
except asyncio.CancelledError:
|
|
59
|
+
pass
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error(f"Keepalive error: {e}")
|
|
62
|
+
|
|
63
|
+
async def _listen(self):
|
|
64
|
+
"""Listens for incoming messages."""
|
|
65
|
+
try:
|
|
66
|
+
while self._connected and self.ws:
|
|
67
|
+
message = await self.ws.recv()
|
|
68
|
+
if message == "3":
|
|
69
|
+
logger.debug("Received heartbeat ack (3)")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads(message)
|
|
74
|
+
msg_type = data.get("MessageType")
|
|
75
|
+
if msg_type:
|
|
76
|
+
self._trigger_callbacks(msg_type, data)
|
|
77
|
+
else:
|
|
78
|
+
logger.warning(f"Unknown message format: {message}")
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
logger.warning(f"Non-JSON message received: {message}")
|
|
81
|
+
except websockets.exceptions.ConnectionClosed:
|
|
82
|
+
logger.info("Connection closed by server.")
|
|
83
|
+
self._connected = False
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Listen error: {e}")
|
|
86
|
+
self._trigger_callbacks("error", e)
|
|
87
|
+
|
|
88
|
+
def _trigger_callbacks(self, event_type: str, data):
|
|
89
|
+
"""Triggers all callbacks registered for an event type."""
|
|
90
|
+
for cb in self._callbacks.get(event_type, []):
|
|
91
|
+
try:
|
|
92
|
+
cb(data)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Error in callback for {event_type}: {e}")
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kkunal
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Kkunal - Python library for Choice FINX Trading API
|
|
5
|
+
Author: Kkunal
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: requests>=2.28.0
|
|
13
|
+
Requires-Dist: pycryptodome>=3.17.0
|
|
14
|
+
Requires-Dist: websockets>=11.0.3
|
|
15
|
+
Requires-Dist: pandas>=1.3.0
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
Dynamic: summary
|
|
24
|
+
|
|
25
|
+
# Kkunal
|
|
26
|
+
|
|
27
|
+
A Python library for the Choice FINX Trading API. Supports REST API, Interactive WebSockets (order/trade updates), and Live Price Feed WebSockets (FIX3.0 compressed data).
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install kkunal
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
All dependencies (`requests`, `pycryptodome`, `websockets`, `pandas`) are installed automatically.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from choice_api import ChoiceClient
|
|
43
|
+
|
|
44
|
+
client = ChoiceClient(
|
|
45
|
+
vendor_id="YOUR_VENDOR_ID",
|
|
46
|
+
vendor_key="YOUR_VENDOR_KEY",
|
|
47
|
+
api_key="YOUR_JWT_BEARER_TOKEN",
|
|
48
|
+
aes_key="YOUR_AES_KEY",
|
|
49
|
+
aes_iv="YOUR_AES_IV"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Login (TOTP flow is handled automatically)
|
|
53
|
+
session_id = client.login(mobile_no="1234567890")
|
|
54
|
+
print(f"Session ID: {session_id}")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Session Persistence
|
|
58
|
+
|
|
59
|
+
You can save and reload sessions to avoid logging in repeatedly during the same trading day:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
session_file = "my_session.json"
|
|
63
|
+
|
|
64
|
+
if client.load_session(session_file):
|
|
65
|
+
print("Restored today's session.")
|
|
66
|
+
else:
|
|
67
|
+
client.login(mobile_no="1234567890")
|
|
68
|
+
client.save_session(session_file)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> **Note:** Sessions expire daily. `load_session` will return `False` if the saved session is from a previous day.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Scrip Master
|
|
76
|
+
|
|
77
|
+
The Scrip Master CSV is automatically downloaded when you log in. It maps instrument symbols to their tokens, lot sizes, and other metadata.
|
|
78
|
+
|
|
79
|
+
### `get_token(symbol, exchange=None)`
|
|
80
|
+
|
|
81
|
+
Returns the token for a given symbol.
|
|
82
|
+
|
|
83
|
+
- For **NSE** instruments, no `exchange` parameter is needed (returns NSE by default).
|
|
84
|
+
- For **BSE** instruments, pass `exchange="BSE"` explicitly.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# NSE (default)
|
|
88
|
+
reliance_token = client.scrip_master.get_token("RELIANCE")
|
|
89
|
+
|
|
90
|
+
# BSE (must specify exchange)
|
|
91
|
+
reliance_bse_token = client.scrip_master.get_token("RELIANCE", exchange="BSE")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `get_details(token)`
|
|
95
|
+
|
|
96
|
+
Returns all CSV row details for a given token as a dictionary.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
details = client.scrip_master.get_details("2885")
|
|
100
|
+
print(details)
|
|
101
|
+
# {'Exchange': 'NSE', 'Segment': '1', 'Token': '2885', 'Symbol': 'RELIANCE', ...}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `get_lot_size(token)`
|
|
105
|
+
|
|
106
|
+
Returns the market lot size for a token.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
lot = client.scrip_master.get_lot_size("2885")
|
|
110
|
+
print(lot) # 1 for equity, 250 for NIFTY futures, etc.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Orders
|
|
116
|
+
|
|
117
|
+
> **Important:** Prices must be in **paisa** (multiply INR by 100). For F&O orders, `qty` must be in **total shares** (multiples of the lot size), not the number of lots.
|
|
118
|
+
|
|
119
|
+
### `client.orders.place_order(...)`
|
|
120
|
+
|
|
121
|
+
| Parameter | Type | Description |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `segment_id` | `int` | `1` = NSE Cash, `2` = NSE F&O, `3` = BSE Cash |
|
|
124
|
+
| `token` | `int` | Instrument token from Scrip Master |
|
|
125
|
+
| `order_type` | `str` | `"RL_MKT"` = Market, `"RL_LIMIT"` = Limit |
|
|
126
|
+
| `bs` | `int` | `1` = Buy, `2` = Sell |
|
|
127
|
+
| `qty` | `int` | Total quantity in shares |
|
|
128
|
+
| `price` | `float` | Price in paisa (e.g., 1300 INR → `130000`) |
|
|
129
|
+
| `trigger_price` | `float` | Trigger price in paisa (0 for non-SL orders) |
|
|
130
|
+
| `validity` | `int` | `1` = Day |
|
|
131
|
+
| `product_type` | `str` | `"M"` = Intraday (Margin), `"D"` = Delivery/CarryForward |
|
|
132
|
+
| `disclosed_qty` | `int` | Optional. Disclosed quantity (default `0`) |
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
response = client.orders.place_order(
|
|
136
|
+
segment_id=1,
|
|
137
|
+
token=2885,
|
|
138
|
+
order_type="RL_MKT",
|
|
139
|
+
bs=1,
|
|
140
|
+
qty=1,
|
|
141
|
+
price=0,
|
|
142
|
+
trigger_price=0,
|
|
143
|
+
validity=1,
|
|
144
|
+
product_type="D"
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `client.orders.modify_order(...)`
|
|
149
|
+
|
|
150
|
+
Modifies an existing order. Requires `client_order_no`, `exchange_order_no`, and `gateway_order_no` from the order book.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
response = client.orders.modify_order(
|
|
154
|
+
client_order_no=123456,
|
|
155
|
+
exchange_order_no="1234567890",
|
|
156
|
+
gateway_order_no="1234567890",
|
|
157
|
+
segment_id=1,
|
|
158
|
+
token=2885,
|
|
159
|
+
order_type="RL_LIMIT",
|
|
160
|
+
bs=1,
|
|
161
|
+
qty=1,
|
|
162
|
+
price=130000,
|
|
163
|
+
trigger_price=0,
|
|
164
|
+
validity=1,
|
|
165
|
+
product_type="D"
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `client.orders.cancel_order(...)`
|
|
170
|
+
|
|
171
|
+
Cancels an existing order. Same parameters as `modify_order` plus optional `exchange_order_time`.
|
|
172
|
+
|
|
173
|
+
### `client.orders.get_order_book()`
|
|
174
|
+
|
|
175
|
+
Returns all orders placed during the current session.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
order_book = client.orders.get_order_book()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `client.orders.get_order_book_v2()`
|
|
182
|
+
|
|
183
|
+
Returns the order book (version 2 format).
|
|
184
|
+
|
|
185
|
+
### `client.orders.get_order_by_no(order_no)`
|
|
186
|
+
|
|
187
|
+
Returns details for a specific order number.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
order = client.orders.get_order_by_no(123456)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `client.orders.get_trade_book()`
|
|
194
|
+
|
|
195
|
+
Returns all executed trades.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
trades = client.orders.get_trade_book()
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `client.orders.get_order_messages(req_id)`
|
|
202
|
+
|
|
203
|
+
Returns order-related messages for a given request ID.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Portfolio
|
|
208
|
+
|
|
209
|
+
### `client.portfolio.get_holdings()`
|
|
210
|
+
|
|
211
|
+
Returns current holdings.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
holdings = client.portfolio.get_holdings()
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### `client.portfolio.get_net_position()`
|
|
218
|
+
|
|
219
|
+
Returns net positions.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
positions = client.portfolio.get_net_position()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `client.portfolio.position_conversion(...)`
|
|
226
|
+
|
|
227
|
+
Converts an open position from one product type to another (e.g., Intraday to Delivery).
|
|
228
|
+
|
|
229
|
+
| Parameter | Type | Description |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `segment_id` | `int` | Exchange segment |
|
|
232
|
+
| `token` | `int` | Instrument token |
|
|
233
|
+
| `client_order_no` | `int` | Client order number |
|
|
234
|
+
| `buy_sell` | `int` | `1` = Buy, `2` = Sell |
|
|
235
|
+
| `quantity` | `int` | Quantity to convert |
|
|
236
|
+
| `product_type` | `str` | Target product type |
|
|
237
|
+
| `source_product_type` | `str` | Current product type |
|
|
238
|
+
|
|
239
|
+
### `client.portfolio.verify_dis(...)`
|
|
240
|
+
|
|
241
|
+
Verifies eDIS (Electronic Delivery Instruction Slip) for delivery sell orders.
|
|
242
|
+
|
|
243
|
+
### `client.portfolio.get_dis_status()`
|
|
244
|
+
|
|
245
|
+
Returns the current DIS verification status.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Funds
|
|
250
|
+
|
|
251
|
+
### `client.funds.get_funds_view()`
|
|
252
|
+
|
|
253
|
+
Returns funds summary.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
funds = client.funds.get_funds_view()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `client.funds.get_funds_view_new()`
|
|
260
|
+
|
|
261
|
+
Returns funds summary in the new format.
|
|
262
|
+
|
|
263
|
+
### `client.funds.process_payout(amount, bank_acc_no, product_type=0)`
|
|
264
|
+
|
|
265
|
+
Initiates a fund withdrawal.
|
|
266
|
+
|
|
267
|
+
### `client.funds.payment_via_netbanking(amount, bank_acc_no, bank_ifsc_code, return_url, segment_id, product_type=0)`
|
|
268
|
+
|
|
269
|
+
Initiates a net banking payment.
|
|
270
|
+
|
|
271
|
+
### `client.funds.payment_via_hdfc_upi(amount, bank_acc_no, user_vpa, segment_id, product_type=0)`
|
|
272
|
+
|
|
273
|
+
Initiates a HDFC UPI payment.
|
|
274
|
+
|
|
275
|
+
### `client.funds.check_vpa(user_vpa)`
|
|
276
|
+
|
|
277
|
+
Validates a UPI VPA address.
|
|
278
|
+
|
|
279
|
+
### `client.funds.payment_via_razorpay(amount, bank_acc_no, bank_ifsc_code, upi_id, segment_id, payment_type=0, product_type=0)`
|
|
280
|
+
|
|
281
|
+
Initiates a RazorPay payment.
|
|
282
|
+
|
|
283
|
+
### `client.funds.payment_ack_response(transaction_id)`
|
|
284
|
+
|
|
285
|
+
Acknowledges a payment transaction.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Market
|
|
290
|
+
|
|
291
|
+
### `client.market.get_market_status()`
|
|
292
|
+
|
|
293
|
+
Returns current market status across all segments.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
status = client.market.get_market_status()
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### `client.market.get_user_profile()`
|
|
300
|
+
|
|
301
|
+
Returns the authenticated user's profile.
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
profile = client.market.get_user_profile()
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### `client.market.get_multiple_touchline(multiple_seg_token)`
|
|
308
|
+
|
|
309
|
+
Returns touchline data for multiple instruments.
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
# Format: "SegmentId1,Token1|SegmentId2,Token2"
|
|
313
|
+
touchline = client.market.get_multiple_touchline("1,2885|1,11536")
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Historical Data
|
|
319
|
+
|
|
320
|
+
### `client.historical.get_historical_data(segment_id, token, from_date, to_date, resolution)`
|
|
321
|
+
|
|
322
|
+
Returns historical OHLCV data as a **Pandas DataFrame**.
|
|
323
|
+
|
|
324
|
+
| Parameter | Type | Description |
|
|
325
|
+
|---|---|---|
|
|
326
|
+
| `segment_id` | `int` | Exchange segment |
|
|
327
|
+
| `token` | `int` | Instrument token |
|
|
328
|
+
| `from_date` | `str` or `int` | Start date (`"YYYY-MM-DD"` or seconds from 1980) |
|
|
329
|
+
| `to_date` | `str` or `int` | End date (`"YYYY-MM-DD"` or seconds from 1980) |
|
|
330
|
+
| `resolution` | `str` | `"1"` = 1 min, `"5"` = 5 min, `"D"` = Daily |
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
df = client.historical.get_historical_data(
|
|
334
|
+
segment_id=1,
|
|
335
|
+
token=2885,
|
|
336
|
+
from_date="2024-01-01",
|
|
337
|
+
to_date="2024-12-31",
|
|
338
|
+
resolution="D"
|
|
339
|
+
)
|
|
340
|
+
print(df.head())
|
|
341
|
+
# Time Open High Low Close Volume OI
|
|
342
|
+
# 0 2024-01-01 00:00:00 2501.00 2520.50 2490.00 2515.30 1234567 0
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The returned DataFrame has columns: `Time`, `Open`, `High`, `Low`, `Close`, `Volume`, `OI`. Prices are automatically adjusted using the `PriceDivisor` from the API response.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Interactive WebSockets
|
|
350
|
+
|
|
351
|
+
Receives live order updates, trade confirmations, and market status events.
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
import asyncio
|
|
355
|
+
from choice_api import InteractiveSocketClient
|
|
356
|
+
|
|
357
|
+
async def main():
|
|
358
|
+
ws = InteractiveSocketClient(token=client.session_id)
|
|
359
|
+
|
|
360
|
+
ws.on("ORD_NRML", lambda data: print(f"Order Update: {data}"))
|
|
361
|
+
ws.on("TRD_MSG", lambda data: print(f"Trade: {data}"))
|
|
362
|
+
ws.on("MKT_STAT", lambda data: print(f"Market Status: {data}"))
|
|
363
|
+
|
|
364
|
+
await ws.connect()
|
|
365
|
+
|
|
366
|
+
asyncio.run(main())
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Event types:** `ORD_NRML` (order updates), `TRD_MSG` (trade confirmations), `MKT_STAT` (market open/close).
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Price Feed WebSockets (FIX3.0)
|
|
374
|
+
|
|
375
|
+
Receives live Level 1 (Touchline) and Level 2 (Best Five / Depth) market data via TCP socket with Zlib compression.
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
import asyncio
|
|
379
|
+
from choice_api import PriceFeedSocketClient
|
|
380
|
+
|
|
381
|
+
async def main():
|
|
382
|
+
feed = PriceFeedSocketClient(
|
|
383
|
+
host=client.bcast_ip,
|
|
384
|
+
port=client.bcast_port,
|
|
385
|
+
user_id="YOUR_USER_ID"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
feed.on_message(lambda raw: print(f"Feed: {raw}"))
|
|
389
|
+
|
|
390
|
+
# Start connection (sends login automatically)
|
|
391
|
+
asyncio.create_task(feed.connect())
|
|
392
|
+
|
|
393
|
+
# Wait for connection, then subscribe
|
|
394
|
+
await asyncio.sleep(2)
|
|
395
|
+
feed.subscribe_touchline(client.session_id, segment_id=1, token=2885)
|
|
396
|
+
feed.subscribe_best_five(client.session_id, segment_id=1, token=2885)
|
|
397
|
+
|
|
398
|
+
# Keep running
|
|
399
|
+
await asyncio.sleep(3600)
|
|
400
|
+
|
|
401
|
+
asyncio.run(main())
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Logoff
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
client.logoff()
|
|
410
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
choice_api/__init__.py,sha256=fY65cwH-JaEdWKAhq3-Ml8-_JHZwFhbld5wmoBU5s3o,570
|
|
2
|
+
choice_api/client.py,sha256=Ak9LYlT7A60DL8DGYQ1FqBBF84OcTxfkrV4f6CcZTQs,7664
|
|
3
|
+
choice_api/funds.py,sha256=i0wAEdgOVbeJ_fKsj48TnJvBPnZd_nvqtCX98S47zYU,2991
|
|
4
|
+
choice_api/historical.py,sha256=3nv9XpRA6vr3wvnVoYnZiYFRDYW_G_S_jaZ0KG5oNBQ,3641
|
|
5
|
+
choice_api/market.py,sha256=EQPy7H6M4NgiMCQwiMTO-T-jn38qUNmCWsvuOWGW7tY,834
|
|
6
|
+
choice_api/orders.py,sha256=Fpm9Y10J5c4dpEQCH7k998q3qzT8OcI5e9CqUkX3kUY,3958
|
|
7
|
+
choice_api/portfolio.py,sha256=5O1iQCzHnkw-2x6mJlfINsVZcn_9jUpHXJmGoEWwzfM,1775
|
|
8
|
+
choice_api/scrip_master.py,sha256=suvihBC94Ju0iE0txHftickOXHySPhEo0kb8pMh14v4,4398
|
|
9
|
+
choice_api/websockets_feed.py,sha256=SQAGFV3CMoQS_1kmE4Uf_IP4hhDq7Ah6DCSrN1c814I,4236
|
|
10
|
+
choice_api/websockets_interactive.py,sha256=bGWNnFcuECmFIAoK9F7yxF5YMBqAokI8_gg6gYch588,3553
|
|
11
|
+
kkunal-1.0.0.dist-info/licenses/LICENSE,sha256=Uov9DNV_BCILceTqoxV67xKBx7xFisUYLV_jas2RWoQ,1062
|
|
12
|
+
kkunal-1.0.0.dist-info/METADATA,sha256=t6cBhWOJcKHbtFINGkDKfd0vsBIvRiDRbfGhjIKLzTk,10212
|
|
13
|
+
kkunal-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
kkunal-1.0.0.dist-info/top_level.txt,sha256=VsLK_s-yRDOGB9DrN_5TkpyT-ejeAXp_kKygPh9_z18,11
|
|
15
|
+
kkunal-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kkunal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT/TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
choice_api
|