bw-essentials-core 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bw-essentials-core might be problematic. Click here for more details.

@@ -0,0 +1,499 @@
1
+ """
2
+ Module for interacting with the Trade Placement Service.
3
+
4
+ This module defines a `TradePlacement` class responsible for managing and executing
5
+ equity trades such as CNC (Cash and Carry) and MTF (Margin Trading Facility) across
6
+ multiple brokers. It handles order placement, authentication, metadata construction,
7
+ and communication with external broker APIs. It supports placing buy/sell orders,
8
+ fetching order details, and executing bulk instructions.
9
+
10
+ Classes:
11
+ TradePlacement: Extends `ApiClient` to interact with Trade Placement Service.
12
+
13
+ Dependencies:
14
+ - bw_essentials.constants.services.Services
15
+ - bw_essentials.services.api_client.ApiClient
16
+ - bw_essentials.services.broker.Broker
17
+ """
18
+ import json
19
+ import logging
20
+ from datetime import datetime
21
+
22
+ from bw_essentials.constants.services import Services
23
+ from bw_essentials.services.api_client import ApiClient
24
+ from bw_essentials.services.broker import Broker
25
+
26
+ logger = logging.getLogger()
27
+
28
+
29
+ class TradePlacement(ApiClient):
30
+ """
31
+ Client to interact with the Trade Placement Service.
32
+
33
+ Attributes:
34
+ base_url (str): Base URL of the Trade Placement Service.
35
+ broker_service_base_url (str): Base URL of the Broker Service.
36
+ user_info (dict): User context including broker, user_id, and entity_id.
37
+ name (str): Name of the service.
38
+ """
39
+ SELL = 'sell'
40
+ BUY = 'buy'
41
+ MARKET = 'market'
42
+ LIMIT = 'limit'
43
+ MTF = 'mtf'
44
+ CNC = 'cnc'
45
+ LKP = "lkp"
46
+ AXIS = "axis"
47
+ ZERODHA = "zerodha"
48
+ PAPER_TRADE = "paper_trade"
49
+ PL = "pl"
50
+ EMKAY = 'emkay'
51
+ DEALER = "dealer"
52
+ USER = "user"
53
+
54
+ EXECUTABLE = 'executable'
55
+ READY_ONLY = 'read_only'
56
+ BROKER_CONFIG_MAPPER = {
57
+ DEALER: READY_ONLY,
58
+ USER: EXECUTABLE
59
+ }
60
+
61
+ def __init__(self,
62
+ service_user: str,
63
+ user_info: dict):
64
+ """
65
+ Initialize TradePlacement client.
66
+
67
+ Args:
68
+ service_user (str): Username for service access.
69
+ user_info (dict): Metadata including broker and user identity.
70
+ """
71
+ logger.info(f"Initializing TradePlacement client for user: {service_user}")
72
+ super().__init__(user=service_user)
73
+ self.base_url = self.get_base_url(Services.TRADE_PLACEMENT.value)
74
+ self.user_info = user_info
75
+ self.broker = self.user_info.get('current_broker')
76
+ self.user_id = self.user_info.get('user_id')
77
+ self.entity_id = self.user_info.get("entity_id")
78
+ self.name = Services.TRADE_PLACEMENT.value
79
+ self.urls = {
80
+ "order": "orders/order",
81
+ "update_orders": "orders/order/update"
82
+ }
83
+ if self.user_info.get("broker_name") in [self.PAPER_TRADE]:
84
+ self._authenticate()
85
+
86
+ def _authenticate(self):
87
+ """
88
+ Authenticates with the broker service if using paper trade broker.
89
+ """
90
+ broker = Broker(service_user=self.user)
91
+ broker.authenticate(broker_name=self.broker,
92
+ user_id=self.user_id,
93
+ entity_id=self.user_id)
94
+
95
+ def _get_cnc_metadata(self, user_portfolio_id, instruction_id, rebalance, user_portfolio_rebalance_id):
96
+ """
97
+ Build metadata dictionary for CNC (Cash and Carry) order.
98
+
99
+ Args:
100
+ user_portfolio_id (str): Portfolio ID of the user.
101
+ instruction_id (str): Unique ID for the instruction.
102
+ rebalance (bool): Indicates whether rebalance is happening.
103
+ user_portfolio_rebalance_id (str): Unique ID for rebalance.
104
+
105
+ Returns:
106
+ dict: Metadata for CNC order.
107
+ """
108
+ user_info = self.user_info or {}
109
+
110
+ return {
111
+ "user_portfolio_id": user_portfolio_id,
112
+ "instruction_id": instruction_id,
113
+ "user_id": user_info.get("user_id"),
114
+ "date": str(datetime.now().date()),
115
+ "rebalance": rebalance,
116
+ "user_portfolio_rebalance_id": user_portfolio_rebalance_id,
117
+ "user_info": {
118
+ "current_broker": user_info.get("current_broker"),
119
+ "broker_name": user_info.get("broker_name"),
120
+ "entity_id": user_info.get("entity_id")
121
+ }
122
+ }
123
+
124
+ def _get_mtf_metadata(self, basket_id, instruction_id):
125
+ """
126
+ Build metadata dictionary for MTF (Margin Trading Facility) order.
127
+
128
+ Args:
129
+ basket_id (int): Basket identifier.
130
+ instruction_id (int): Instruction identifier.
131
+
132
+ Returns:
133
+ dict: Metadata for MTF order.
134
+ """
135
+ user_info = self.user_info or {} # Use an empty dictionary as a fallback
136
+
137
+ return {
138
+ "user_id": user_info.get("user_id"),
139
+ "date": str(datetime.now().date()),
140
+ "basket_id": basket_id,
141
+ "instruction_id": instruction_id,
142
+ "user_info": {
143
+ "current_broker": user_info.get("current_broker"),
144
+ "broker_name": user_info.get("broker_name"),
145
+ "entity_id": user_info.get("entity_id")
146
+ }
147
+ }
148
+
149
+ def _place_order(self, trading_symbol, qty, side, order_tag, meta_data, product, order_type, proxy=None,
150
+ asm_consent=None, asm_reason=None):
151
+ """
152
+ Places an order with the provided metadata and order details.
153
+
154
+ Args:
155
+ trading_symbol (str): Symbol to trade.
156
+ qty (int): Quantity to trade.
157
+ side (str): Order side ('buy' or 'sell').
158
+ order_tag (str): Identifier tag for the order.
159
+ meta_data (dict): Metadata about the order context.
160
+ product (str): Type of product ('cnc' or 'mtf').
161
+ order_type (str): Type of order ('market', 'limit').
162
+ proxy (str, optional): Indicates if proxy config is used.
163
+ asm_consent (str, optional): Consent for ASM if applicable.
164
+ asm_reason (str, optional): Reason for ASM if applicable.
165
+
166
+ Returns:
167
+ dict: Response containing placed order details.
168
+ """
169
+ logger.info(f"In - _place_order {trading_symbol =}, {qty =}, {side =}, {order_tag =}"
170
+ f"{proxy =}")
171
+ broker_config = self.EXECUTABLE
172
+ if proxy == self.READY_ONLY:
173
+ broker_config = self.READY_ONLY
174
+
175
+ payload = json.dumps({
176
+ "user_id": self.user_info.get("user_id"),
177
+ "entity_id": self.user_info.get("entity_id"),
178
+ "symbol": trading_symbol,
179
+ "quantity": abs(qty),
180
+ "broker": self.user_info.get("broker_name"),
181
+ "broker_config": broker_config,
182
+ "price": 0,
183
+ "product_type": product,
184
+ "order_type": order_type,
185
+ "side": side,
186
+ "tag": order_tag,
187
+ "metadata": meta_data,
188
+ "asm_consent": asm_consent,
189
+ "asm_reason": asm_reason
190
+ })
191
+ placed_orders_data = self._post(url=self.base_url,
192
+ endpoint=self.urls.get("order"),
193
+ data=payload)
194
+ return placed_orders_data.get("data")
195
+
196
+ def _market_buy_cnc(self, trading_symbol, qty, generate_order_tag,
197
+ user_portfolio_id, instruction_id, rebalance,
198
+ user_portfolio_rebalance_id, proxy=None,
199
+ asm_consent=None, asm_reason=None):
200
+ """
201
+ Places a market buy order using CNC.
202
+
203
+ Args:
204
+ trading_symbol (str): Symbol to buy.
205
+ qty (int): Quantity to buy.
206
+ generate_order_tag (str): Order tag.
207
+ user_portfolio_id (str): Portfolio ID.
208
+ instruction_id (str): Instruction ID.
209
+ rebalance (bool): Rebalance flag.
210
+ user_portfolio_rebalance_id (str): Rebalance ID.
211
+ proxy (str, optional): Proxy config.
212
+ asm_consent (str, optional): ASM consent.
213
+ asm_reason (str, optional): ASM reason.
214
+
215
+ Returns:
216
+ dict: Order response data.
217
+ """
218
+ logger.info(f"Placing CNC market buy order for {trading_symbol=}, {qty=}")
219
+ meta_data = self._get_cnc_metadata(user_portfolio_id, instruction_id, rebalance, user_portfolio_rebalance_id)
220
+ return self._place_order(trading_symbol, qty, self.BUY, generate_order_tag,
221
+ meta_data, self.CNC, self.MARKET,
222
+ proxy=proxy, asm_consent=asm_consent, asm_reason=asm_reason)
223
+
224
+ def _market_sell_cnc(self, trading_symbol, qty, generate_order_tag,
225
+ user_portfolio_id, instruction_id, rebalance,
226
+ user_portfolio_rebalance_id, proxy=None,
227
+ asm_consent=None, asm_reason=None):
228
+ """
229
+ Places a market sell order using CNC.
230
+
231
+ Args:
232
+ trading_symbol (str): Symbol to sell.
233
+ qty (int): Quantity to sell.
234
+ generate_order_tag (str): Order tag.
235
+ user_portfolio_id (str): Portfolio ID.
236
+ instruction_id (str): Instruction ID.
237
+ rebalance (bool): Rebalance flag.
238
+ user_portfolio_rebalance_id (str): Rebalance ID.
239
+ proxy (str, optional): Proxy config.
240
+ asm_consent (str, optional): ASM consent.
241
+ asm_reason (str, optional): ASM reason.
242
+
243
+ Returns:
244
+ dict: Order response data.
245
+ """
246
+ logger.info(f"Placing CNC market sell order for {trading_symbol=}, {qty=}")
247
+ meta_data = self._get_cnc_metadata(user_portfolio_id, instruction_id, rebalance, user_portfolio_rebalance_id)
248
+ return self._place_order(trading_symbol, qty, self.SELL, generate_order_tag,
249
+ meta_data, self.CNC, self.MARKET,
250
+ proxy=proxy, asm_consent=asm_consent, asm_reason=asm_reason)
251
+
252
+ def _market_buy_mtf(self, trading_symbol, qty, generate_order_tag, basket_id, instruction_id, proxy=None):
253
+ """
254
+ Places a market buy order using MTF.
255
+
256
+ Args:
257
+ trading_symbol (str): Symbol to buy.
258
+ qty (int): Quantity to buy.
259
+ generate_order_tag (str): Order tag.
260
+ basket_id (int): Basket ID.
261
+ instruction_id (int): Instruction ID.
262
+ proxy (str, optional): Proxy config.
263
+
264
+ Returns:
265
+ dict: Order response data.
266
+ """
267
+ logger.info(f"Placing MTF market buy order for {trading_symbol=}, {qty=}")
268
+ meta_data = self._get_mtf_metadata(basket_id, instruction_id)
269
+ return self._place_order(trading_symbol, qty, self.BUY, generate_order_tag,
270
+ meta_data, self.MTF, self.MARKET, proxy=proxy)
271
+
272
+ def _market_sell_mtf(self, trading_symbol, qty, generate_order_tag, basket_id, instruction_id, proxy=None):
273
+ """
274
+ Places a market sell order using MTF.
275
+
276
+ Args:
277
+ trading_symbol (str): Symbol to sell.
278
+ qty (int): Quantity to sell.
279
+ generate_order_tag (str): Order tag.
280
+ basket_id (int): Basket ID.
281
+ instruction_id (int): Instruction ID.
282
+ proxy (str, optional): Proxy config.
283
+
284
+ Returns:
285
+ dict: Order response data.
286
+ """
287
+ logger.info(f"Placing MTF market sell order for {trading_symbol=}, {qty=}")
288
+ meta_data = self._get_mtf_metadata(basket_id, instruction_id)
289
+ return self._place_order(trading_symbol, qty, self.SELL, generate_order_tag,
290
+ meta_data, self.MTF, self.MARKET, proxy=proxy)
291
+
292
+ def get_order_details(self, order_id):
293
+ """
294
+ Fetch order details by order ID.
295
+
296
+ Args:
297
+ order_id (str): Unique ID of the order.
298
+
299
+ Returns:
300
+ dict: Detailed order information.
301
+ """
302
+ logger.info(f"Fetching order details for {order_id=}")
303
+ order_details = self._get(url=self.base_url,
304
+ endpoint=f"{self.urls.get('order')}/{order_id}")
305
+ return order_details.get("data")
306
+
307
+ def execute_sell_orders_cnc(self, instructions, request_data, portfolio_rebalance_id):
308
+ """
309
+ Executes a list of CNC sell instructions.
310
+
311
+ Args:
312
+ instructions (list): List of sell order dictionaries.
313
+ request_data (dict): Metadata including portfolio info.
314
+ portfolio_rebalance_id (str): Rebalance ID.
315
+ """
316
+ logger.info(f"Executing CNC sell orders - {instructions=}")
317
+ for orders in instructions:
318
+ data = self._market_sell_cnc(trading_symbol=orders.get("symbol"),
319
+ qty=orders.get("qty"),
320
+ generate_order_tag=orders.get("tag"),
321
+ user_portfolio_id=request_data.get("user_portfolio_id"),
322
+ instruction_id=orders.get("instruction_id"),
323
+ rebalance=request_data.get("rebalance"),
324
+ user_portfolio_rebalance_id=portfolio_rebalance_id,
325
+ asm_consent=orders.get('asm_consent'),
326
+ asm_reason=orders.get('asm_reason'))
327
+ logger.info(f"CNC sell executed: {data=}")
328
+
329
+ def execute_sell_orders_mtf(self, instructions, basket_id):
330
+ """
331
+ Executes sell orders for the Multi-Trade Fund (MTF).
332
+
333
+ This method iterates through a list of sell instructions and places market sell
334
+ orders for each security based on the given instructions. It uses the `_market_sell_mtf`
335
+ method to handle the order execution.
336
+
337
+ Args:
338
+ instructions (list): A list of dictionaries containing sell order details.
339
+ Each dictionary includes:
340
+ - "symbol" (str): The trading symbol of the security.
341
+ - "qty" (int): The quantity of the security to sell.
342
+ - "tag" (bool): Indicates whether to generate an order tag for the sell order.
343
+
344
+ basket_id (int): The unique identifier for the basket.
345
+
346
+ Returns:
347
+ None
348
+ """
349
+ logger.info(f"In execute_orders - {instructions =}")
350
+ for orders in instructions:
351
+ data = self._market_sell_mtf(
352
+ trading_symbol=orders.get("symbol"),
353
+ qty=orders.get("qty"),
354
+ generate_order_tag=orders.get("tag"),
355
+ basket_id=basket_id,
356
+ instruction_id=orders.get("instruction_id")
357
+ )
358
+ logger.info(f"execute_orders sell {data = }")
359
+
360
+ def execute_buy_orders_cnc(self, instructions, request_data, portfolio_rebalance_id):
361
+ """
362
+ Execute a list of orders.
363
+
364
+ Args:
365
+ instructions (dict): Instructions for executing orders.
366
+ """
367
+ logger.info(f"In execute_buy_orders - {instructions =}, {request_data =}, {portfolio_rebalance_id =}")
368
+ for orders in instructions:
369
+ data = self._market_buy_cnc(trading_symbol=orders.get("symbol"),
370
+ qty=orders.get("qty"),
371
+ generate_order_tag=orders.get("tag"),
372
+ user_portfolio_id=request_data.get("user_portfolio_id"),
373
+ instruction_id=orders.get("instruction_id"),
374
+ rebalance=request_data.get("rebalance"),
375
+ user_portfolio_rebalance_id=portfolio_rebalance_id,
376
+ asm_consent=orders.get('asm_consent'),
377
+ asm_reason=orders.get('asm_reason')
378
+ )
379
+ logger.info(f"execute_buy_orders {data = }")
380
+
381
+ def execute_buy_orders_mtf(self, instructions, basket_id):
382
+ """
383
+ Executes buy orders for the Multi-Trade Fund (MTF).
384
+
385
+ This method processes a list of buy instructions and places market buy
386
+ orders for each security. It utilizes the `_market_buy_mtf` method for
387
+ executing the orders based on the provided instructions.
388
+
389
+ Args:
390
+ instructions (list): A list of dictionaries containing buy order details.
391
+ Each dictionary includes:
392
+ - "symbol" (str): The trading symbol of the security to buy.
393
+ - "qty" (int): The quantity of the security to buy.
394
+ - "tag" (bool): Indicates whether to generate an order tag for the buy order.
395
+ for order processing.
396
+ basket_id (int): The unique identifier for the basket.
397
+
398
+ Returns:
399
+ None
400
+ """
401
+ logger.info(f"In execute_buy_orders_mtf - {instructions =}")
402
+ for orders in instructions:
403
+ data = self._market_buy_mtf(
404
+ trading_symbol=orders.get("symbol"),
405
+ qty=orders.get("qty"),
406
+ generate_order_tag=orders.get("tag"),
407
+ basket_id=basket_id,
408
+ instruction_id=orders.get("instruction_id")
409
+ )
410
+ logger.info(f"execute_buy_orders_mtf {data = }")
411
+
412
+ def create_draft_orders(self, instructions, request_data, portfolio_rebalance_id):
413
+ """
414
+ Create draft buy/sell CNC (Cash & Carry) market orders based on provided instructions.
415
+
416
+ This method iterates over a list of order instructions and dispatches them to the appropriate
417
+ order creation method (`_market_buy_cnc` or `_market_sell_cnc`) depending on the order side.
418
+
419
+ Parameters:
420
+ ----------
421
+ instructions : list[dict]
422
+ A list of order instruction dictionaries. Each dictionary must include:
423
+ - 'side' : str ('BUY' or 'SELL')
424
+ - 'symbol' : str
425
+ - 'qty' : int
426
+ - 'tag' : str
427
+ - 'instruction_id' : str
428
+
429
+ request_data : dict
430
+ A dictionary containing contextual information about the rebalance operation.
431
+ Must include:
432
+ - 'user_portfolio_id' : str
433
+ - 'rebalance' : bool or dict
434
+
435
+ portfolio_rebalance_id : str or int
436
+ The ID associated with the current user portfolio rebalance session.
437
+
438
+ Raises:
439
+ ------
440
+ ValueError:
441
+ If an instruction has an unrecognized `side` value.
442
+ """
443
+ logger.info(
444
+ f"In draft_orders - {instructions =}, {request_data =}, {portfolio_rebalance_id =} ")
445
+
446
+ for order in instructions:
447
+ side = order.get("side")
448
+ if side == self.BUY:
449
+ method = self._market_buy_cnc
450
+ elif side == self.SELL:
451
+ method = self._market_sell_cnc
452
+ else:
453
+ raise ValueError(f"Invalid order side '{side}' for instruction: {order}")
454
+
455
+ method(
456
+ trading_symbol=order.get("symbol"),
457
+ qty=order.get("qty"),
458
+ generate_order_tag=order.get("tag"),
459
+ user_portfolio_id=request_data.get("user_portfolio_id"),
460
+ instruction_id=order.get("instruction_id"),
461
+ rebalance=request_data.get("rebalance"),
462
+ user_portfolio_rebalance_id=portfolio_rebalance_id,
463
+ proxy=self.READY_ONLY
464
+ )
465
+
466
+ def update_orders(self, instructions):
467
+ """
468
+ Update multiple orders based on the provided instructions.
469
+
470
+ This method takes a list of order instructions, iterates through each instruction,
471
+ and sends a PUT request to update the order details on the broker's server.
472
+
473
+ Args:
474
+ instructions (list): A list of dictionaries, where each dictionary contains the details
475
+ of an order that needs to be updated. Each dictionary typically
476
+ includes keys such as 'tag', 'symbol', 'quantity', 'side',
477
+ 'order_price', etc.
478
+
479
+ Returns:
480
+ None
481
+
482
+ Raises:
483
+ HTTPError: If the PUT request to update an order fails or returns an error.
484
+
485
+ Logs:
486
+ - Logs the start of the update process and each order update attempt.
487
+ - Logs detailed information about the instructions being processed.
488
+ """
489
+ logger.info(f"In update_orders {instructions =}")
490
+ response = {}
491
+ for instruction in instructions:
492
+ tag = instruction.get('tag')
493
+ logger.info(f"Updating order for {tag =}")
494
+ response = self._put(
495
+ url=self.base_url,
496
+ endpoint=f'{self.urls.get("update_orders")}/{tag}',
497
+ data=json.dumps(instruction)
498
+ )
499
+ return response