finbourne-sdk-utils 0.0.24__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.
Files changed (52) hide show
  1. features/__init__.py +0 -0
  2. features/main.py +11 -0
  3. finbourne_sdk_utils/__init__.py +8 -0
  4. finbourne_sdk_utils/cocoon/__init__.py +34 -0
  5. finbourne_sdk_utils/cocoon/async_tools.py +94 -0
  6. finbourne_sdk_utils/cocoon/cocoon.py +1862 -0
  7. finbourne_sdk_utils/cocoon/cocoon_printer.py +455 -0
  8. finbourne_sdk_utils/cocoon/config/domain_settings.json +125 -0
  9. finbourne_sdk_utils/cocoon/config/seed_sample_data.json +36 -0
  10. finbourne_sdk_utils/cocoon/dateorcutlabel.py +198 -0
  11. finbourne_sdk_utils/cocoon/instruments.py +482 -0
  12. finbourne_sdk_utils/cocoon/properties.py +442 -0
  13. finbourne_sdk_utils/cocoon/seed_sample_data.py +137 -0
  14. finbourne_sdk_utils/cocoon/systemConfiguration.py +92 -0
  15. finbourne_sdk_utils/cocoon/transaction_type_upload.py +136 -0
  16. finbourne_sdk_utils/cocoon/utilities.py +1877 -0
  17. finbourne_sdk_utils/cocoon/validator.py +243 -0
  18. finbourne_sdk_utils/extract/__init__.py +1 -0
  19. finbourne_sdk_utils/extract/group_holdings.py +400 -0
  20. finbourne_sdk_utils/iam/__init__.py +1 -0
  21. finbourne_sdk_utils/iam/roles.py +74 -0
  22. finbourne_sdk_utils/jupyter_tools/__init__.py +2 -0
  23. finbourne_sdk_utils/jupyter_tools/hide_code_button.py +23 -0
  24. finbourne_sdk_utils/jupyter_tools/stop_execution.py +14 -0
  25. finbourne_sdk_utils/logger/LusidLogger.py +41 -0
  26. finbourne_sdk_utils/logger/__init__.py +1 -0
  27. finbourne_sdk_utils/lpt/__init__.py +0 -0
  28. finbourne_sdk_utils/lpt/back_compat.py +20 -0
  29. finbourne_sdk_utils/lpt/cash_ladder.py +191 -0
  30. finbourne_sdk_utils/lpt/connect_lusid.py +64 -0
  31. finbourne_sdk_utils/lpt/connect_none.py +5 -0
  32. finbourne_sdk_utils/lpt/connect_token.py +9 -0
  33. finbourne_sdk_utils/lpt/dfq.py +321 -0
  34. finbourne_sdk_utils/lpt/either.py +65 -0
  35. finbourne_sdk_utils/lpt/get_instruments.py +101 -0
  36. finbourne_sdk_utils/lpt/lpt.py +374 -0
  37. finbourne_sdk_utils/lpt/lse.py +188 -0
  38. finbourne_sdk_utils/lpt/map_instruments.py +164 -0
  39. finbourne_sdk_utils/lpt/pager.py +32 -0
  40. finbourne_sdk_utils/lpt/record.py +13 -0
  41. finbourne_sdk_utils/lpt/refreshing_token.py +43 -0
  42. finbourne_sdk_utils/lpt/search_instruments.py +48 -0
  43. finbourne_sdk_utils/lpt/stdargs.py +154 -0
  44. finbourne_sdk_utils/lpt/txn_config.py +128 -0
  45. finbourne_sdk_utils/lpt/txn_config_yaml.py +493 -0
  46. finbourne_sdk_utils/pandas_utils/__init__.py +0 -0
  47. finbourne_sdk_utils/pandas_utils/lusid_pandas.py +128 -0
  48. finbourne_sdk_utils-0.0.24.dist-info/LICENSE +21 -0
  49. finbourne_sdk_utils-0.0.24.dist-info/METADATA +25 -0
  50. finbourne_sdk_utils-0.0.24.dist-info/RECORD +52 -0
  51. finbourne_sdk_utils-0.0.24.dist-info/WHEEL +5 -0
  52. finbourne_sdk_utils-0.0.24.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1862 @@
1
+ import asyncio
2
+ import concurrent
3
+ import uuid
4
+
5
+ import lusid
6
+ import pandas as pd
7
+ import json
8
+
9
+ from typing import List, Tuple
10
+
11
+ from finbourne_sdk_utils import cocoon
12
+ from finbourne_sdk_utils.cocoon.async_tools import run_in_executor, ThreadPool
13
+ from finbourne_sdk_utils.cocoon.dateorcutlabel import DateOrCutLabel
14
+ from finbourne_sdk_utils.cocoon.utilities import (
15
+ checkargs,
16
+ strip_whitespace,
17
+ group_request_into_one,
18
+ extract_unique_portfolio_codes,
19
+ extract_unique_portfolio_codes_effective_at_tuples,
20
+ get_attributes_and_types
21
+ )
22
+ from finbourne_sdk_utils.cocoon.validator import Validator
23
+ from datetime import datetime
24
+ import pytz
25
+ import logging
26
+
27
+
28
+ class BatchLoader:
29
+ """
30
+ This class contains all the methods used for loading data in batches. The @run_in_executor decorator makes the
31
+ synchronous functions awaitable
32
+ """
33
+
34
+ @staticmethod
35
+ @run_in_executor
36
+ def load_instrument_batch(
37
+ api_factory: lusid.SyncApiClientFactory, instrument_batch: list, **kwargs
38
+ ) -> lusid.models.UpsertInstrumentsResponse:
39
+ """
40
+ Upserts a batch of instruments to LUSID
41
+
42
+ Parameters
43
+ ----------
44
+ api_factory : lusid.SyncApiClientFactory
45
+ The api factory to use
46
+ instrument_batch : list[lusid.models.InstrumentDefinition]
47
+ The batch of instruments to upsert
48
+ **kwargs
49
+ arguments specific to each call e.g. effective_at for holdings, unique_identifiers
50
+
51
+ Returns
52
+ -------
53
+ lusid.models.UpsertInstrumentsResponse
54
+ The response from LUSID
55
+ """
56
+
57
+ # Ensure that the list of allowed unique identifiers exists
58
+ if "unique_identifiers" not in list(kwargs.keys()):
59
+ unique_identifiers = cocoon.instruments.get_unique_identifiers(
60
+ api_factory=api_factory
61
+ )
62
+ else:
63
+ unique_identifiers = kwargs["unique_identifiers"]
64
+
65
+ @checkargs
66
+ def get_alphabetically_first_identifier_key(
67
+ instrument: lusid.models.InstrumentDefinition, unique_identifiers: list
68
+ ):
69
+ """
70
+ Gets the alphabetically first occurring unique identifier on an instrument and use it as the correlation
71
+ id on the request
72
+
73
+ Parameters
74
+ ----------
75
+ instrument : lusid.models.InstrumentDefinition
76
+ The instrument to create a correlation id for
77
+ unique_identifiers : list[str] unique_identifiers
78
+ The list of allowed unique identifiers
79
+
80
+ Returns
81
+ -------
82
+ str
83
+ The correlation id to use on the request
84
+ """
85
+
86
+ unique_identifiers_populated = list(
87
+ set(unique_identifiers).intersection(
88
+ set(list(instrument.identifiers.keys()))
89
+ )
90
+ )
91
+ unique_identifiers_populated.sort()
92
+ first_unique_identifier_alphabetically = unique_identifiers_populated[0]
93
+ return f"{first_unique_identifier_alphabetically}: {instrument.identifiers[first_unique_identifier_alphabetically].value}"
94
+
95
+ # If scope is not defined set to default scope
96
+ return api_factory.build(lusid.api.InstrumentsApi).upsert_instruments(
97
+ scope=kwargs["instrument_scope"],
98
+ request_body={
99
+ get_alphabetically_first_identifier_key(
100
+ instrument, unique_identifiers
101
+ ): instrument
102
+ for instrument in instrument_batch
103
+ },
104
+ )
105
+
106
+ @staticmethod
107
+ @run_in_executor
108
+ def load_quote_batch(
109
+ api_factory: lusid.SyncApiClientFactory, quote_batch: list, **kwargs
110
+ ) -> lusid.models.UpsertQuotesResponse:
111
+ """
112
+ Upserts a batch of quotes into LUSID
113
+
114
+ Parameters
115
+ ----------
116
+ api_factory : lusid.SyncApiClientFactory
117
+ The api factory to use
118
+ quote_batch : list[lusid.models.UpsertQuoteRequest]
119
+ The batch of quotes to upsert
120
+ kwargs
121
+ scope
122
+
123
+ Returns
124
+ -------
125
+ lusid.models.UpsertQuotesResponse
126
+ The response from LUSID
127
+
128
+ """
129
+
130
+ if "scope" not in list(kwargs.keys()):
131
+ raise KeyError(
132
+ "You are trying to load quotes without a scope, please ensure that a scope is provided."
133
+ )
134
+
135
+ return api_factory.build(lusid.api.QuotesApi).upsert_quotes(
136
+ scope=kwargs["scope"],
137
+ request_body={
138
+ "_".join(
139
+ [
140
+ quote.quote_id.quote_series_id.instrument_id,
141
+ quote.quote_id.quote_series_id.instrument_id_type,
142
+ str(quote.quote_id.effective_at),
143
+ ]
144
+ ): quote
145
+ for quote in quote_batch
146
+ },
147
+ )
148
+
149
+ @staticmethod
150
+ @run_in_executor
151
+ def load_transaction_batch(
152
+ api_factory: lusid.SyncApiClientFactory, transaction_batch: list, **kwargs
153
+ ) -> lusid.models.UpsertPortfolioTransactionsResponse:
154
+ """
155
+ Upserts a batch of transactions into LUSID
156
+
157
+ Parameters
158
+ ----------
159
+ api_factory : lusid.SyncApiClientFactory
160
+ The api factory to use
161
+ code : str
162
+ The code of the TransactionPortfolio to upsert the transactions into
163
+ transaction_batch : list[lusid.models.TransactionRequest]
164
+ The batch of transactions to upsert
165
+ kwargs
166
+ code -The code of the TransactionPortfolio to upsert the transactions into
167
+
168
+ Returns
169
+ -------
170
+ lusid.models.UpsertPortfolioTransactionsResponse
171
+ The response from LUSID
172
+ """
173
+
174
+ if "scope" not in list(kwargs.keys()):
175
+ raise KeyError(
176
+ "You are trying to load transactions without a scope, please ensure that a scope is provided."
177
+ )
178
+
179
+ if "code" not in list(kwargs.keys()):
180
+ raise KeyError(
181
+ "You are trying to load transactions without a portfolio code, please ensure that a code is provided."
182
+ )
183
+
184
+ return api_factory.build(
185
+ lusid.api.TransactionPortfoliosApi
186
+ ).upsert_transactions(
187
+ scope=kwargs["scope"],
188
+ code=kwargs["code"],
189
+ transaction_request=transaction_batch,
190
+ )
191
+
192
+ @staticmethod
193
+ @run_in_executor
194
+ def load_transactions_with_commit_mode_batch(
195
+ api_factory: lusid.SyncApiClientFactory, transaction_batch: List, **kwargs
196
+ ) -> lusid.models.UpsertPortfolioTransactionsResponse:
197
+ """
198
+ Upserts a batch of transactions into LUSID with specified type of upsert.
199
+
200
+ Parameters
201
+ ----------
202
+ api_factory : lusid.SyncApiClientFactory
203
+ The api factory to use
204
+ code : str
205
+ The code of the TransactionPortfolio to upsert the transactions into
206
+ transaction_batch : List[lusid.models.TransactionRequest]
207
+ The batch of transactions to upsert
208
+ kwargs
209
+ code -The code of the TransactionPortfolio to upsert the transactions into
210
+ transactions_commit_mode - The type of upsert method to use (Atomic or Partial)
211
+
212
+ Returns
213
+ -------
214
+ lusid.models.BatchUpsertPortfolioTransactionsResponse
215
+ The response from LUSID
216
+ """
217
+
218
+ if "scope" not in list(kwargs.keys()):
219
+ raise KeyError(
220
+ "You are trying to load transactions without a scope, please ensure that a scope is provided."
221
+ )
222
+
223
+ if "code" not in list(kwargs.keys()):
224
+ raise KeyError(
225
+ "You are trying to load transactions without a portfolio code, please ensure that a code is provided."
226
+ )
227
+
228
+ if kwargs["transactions_commit_mode"] is None:
229
+ raise KeyError(
230
+ "You are trying to load transactions without a commit mode (transaction_commit_mode), please provide a commit mode (Atomic or Partial)."
231
+ )
232
+
233
+ if kwargs["transactions_commit_mode"].lower() not in ("atomic", "partial"):
234
+ raise KeyError(
235
+ "You are trying to load transactions without an accepted type of commit mode, please provide either Atomic or Partial to the commit mode."
236
+ )
237
+
238
+ request_body = {
239
+ f"transaction_{idx}": transaction
240
+ for idx, transaction in enumerate(transaction_batch)
241
+ }
242
+
243
+
244
+ return api_factory.build(
245
+ lusid.api.TransactionPortfoliosApi
246
+ ).batch_upsert_transactions(
247
+ scope=kwargs["scope"],
248
+ code=kwargs["code"],
249
+ success_mode=kwargs["transactions_commit_mode"],
250
+ request_body=request_body
251
+ )
252
+
253
+ @staticmethod
254
+ @run_in_executor
255
+ def load_holding_batch(
256
+ api_factory: lusid.SyncApiClientFactory, holding_batch: list, **kwargs
257
+ ) -> lusid.models.HoldingsAdjustment:
258
+ """
259
+ Upserts a batch of holdings into LUSID
260
+
261
+ Parameters
262
+ ----------
263
+ api_factory : lusid.SyncApiClientFactory
264
+ The api factory to use
265
+ holding_batch : list[lusid.models.AdjustHoldingRequest]
266
+ The batch of holdings
267
+ scope : str
268
+ The scope to upsert holdings into
269
+ code : str
270
+ The code of the portfolio to upsert holdings into
271
+ effective_at : str/Datetime/np.datetime64/np.ndarray/pd.Timestamp
272
+ The effective date of the holdings batch
273
+ kwargs
274
+
275
+ Returns
276
+ -------
277
+ lusid.models.HoldingsAdjustment
278
+ The response from LUSID
279
+
280
+ """
281
+
282
+ if "scope" not in list(kwargs.keys()):
283
+ raise KeyError(
284
+ "You are trying to load transactions without a scope, please ensure that a scope is provided."
285
+ )
286
+
287
+ if "code" not in list(kwargs.keys()):
288
+ raise KeyError(
289
+ "You are trying to load transactions without a portfolio code, please ensure that a code is provided."
290
+ )
291
+
292
+ if "effective_at" not in list(kwargs.keys()):
293
+ raise KeyError(
294
+ """There is no mapping for effective_at in the required mapping, please add it"""
295
+ )
296
+
297
+ # If only an adjustment has been specified
298
+ if (
299
+ "holdings_adjustment_only" in list(kwargs.keys())
300
+ and kwargs["holdings_adjustment_only"]
301
+ ):
302
+ return api_factory.build(
303
+ lusid.api.TransactionPortfoliosApi
304
+ ).adjust_holdings(
305
+ scope=kwargs["scope"],
306
+ code=kwargs["code"],
307
+ effective_at=str(DateOrCutLabel(kwargs["effective_at"])),
308
+ adjust_holding_request=holding_batch,
309
+ )
310
+
311
+ return api_factory.build(lusid.api.TransactionPortfoliosApi).set_holdings(
312
+ scope=kwargs["scope"],
313
+ code=kwargs["code"],
314
+ effective_at=str(DateOrCutLabel(kwargs["effective_at"])),
315
+ adjust_holding_request=holding_batch,
316
+ )
317
+
318
+ @staticmethod
319
+ @run_in_executor
320
+ def load_portfolio_batch(
321
+ api_factory: lusid.SyncApiClientFactory, portfolio_batch: list, **kwargs
322
+ ) -> lusid.models.Portfolio:
323
+ """
324
+ Upserts a batch of portfolios to LUSID
325
+
326
+ Parameters
327
+ ----------
328
+ api_factory : lusid.SyncApiClientFactory
329
+ the api factory to use
330
+ portfolio_batch : list[lusid.models.CreateTransactionPortfolioRequest]
331
+ The batch of portfolios to create
332
+ scope : str
333
+ The scope to create the portfolios in
334
+ code : str
335
+ The code of the portfolio to create
336
+ kwargs
337
+
338
+ Returns
339
+ -------
340
+ lusid.models.Portfolio
341
+ the response from LUSID
342
+ """
343
+
344
+ if "scope" not in list(kwargs.keys()):
345
+ raise KeyError(
346
+ "You are trying to load transactions without a scope, please ensure that a scope is provided."
347
+ )
348
+
349
+ if "code" not in list(kwargs.keys()):
350
+ raise KeyError(
351
+ "You are trying to load transactions without a portfolio code, please ensure that a code is provided."
352
+ )
353
+
354
+ try:
355
+ return api_factory.build(lusid.api.PortfoliosApi).get_portfolio(
356
+ scope=kwargs["scope"], code=kwargs["code"]
357
+ )
358
+ # Add in here upsert portfolio properties if it does exist
359
+ except lusid.exceptions.ApiException as e:
360
+ if e.status == 404:
361
+ return api_factory.build(
362
+ lusid.api.TransactionPortfoliosApi
363
+ ).create_portfolio(
364
+ scope=kwargs["scope"],
365
+ create_transaction_portfolio_request=portfolio_batch[0],
366
+ )
367
+ else:
368
+ return e
369
+
370
+ @staticmethod
371
+ @run_in_executor
372
+ def load_reference_portfolio_batch(
373
+ api_factory: lusid.SyncApiClientFactory,
374
+ reference_portfolio_batch: list,
375
+ **kwargs,
376
+ ) -> lusid.models.Portfolio:
377
+ """
378
+ Upserts a batch of reference portfolios to LUSID
379
+
380
+ Parameters
381
+ ----------
382
+ api_factory : lusid.SyncApiClientFactory
383
+ the api factory to use
384
+ portfolio_batch : list[lusid.models.CreateReferencePortfolioRequest]
385
+ The batch of reference portfolios to create
386
+ scope : str
387
+ The scope to create the reference portfolios in
388
+ code : str
389
+ The code of the reference portfolio to create
390
+ kwargs
391
+
392
+ Returns
393
+ -------
394
+ lusid.models.ReferencePortfolio
395
+ the response from LUSID
396
+ """
397
+
398
+ if "scope" not in kwargs.keys():
399
+ raise KeyError(
400
+ "You are trying to load a reference portfolio without a scope, please ensure that a scope is provided."
401
+ )
402
+
403
+ if "code" not in kwargs.keys():
404
+ raise KeyError(
405
+ "You are trying to load a reference portfolio without a portfolio code, please ensure that a code is provided."
406
+ )
407
+
408
+ try:
409
+ return api_factory.build(lusid.api.PortfoliosApi).get_portfolio(
410
+ scope=kwargs["scope"], code=kwargs["code"]
411
+ )
412
+ # TODO: Add in here upsert portfolio properties if it does exist
413
+
414
+ except lusid.exceptions.ApiException as e:
415
+ if e.status == 404:
416
+ return api_factory.build(
417
+ lusid.api.ReferencePortfolioApi
418
+ ).create_reference_portfolio(
419
+ scope=kwargs["scope"],
420
+ create_reference_portfolio_request=reference_portfolio_batch[0],
421
+ )
422
+ else:
423
+ return e
424
+
425
+ @staticmethod
426
+ @run_in_executor
427
+ def load_instrument_property_batch(
428
+ api_factory: lusid.SyncApiClientFactory, property_batch: list, **kwargs
429
+ ) -> List[lusid.models.UpsertInstrumentPropertiesResponse]:
430
+ """
431
+ Add properties to the set instruments
432
+
433
+ Parameters
434
+ ----------
435
+ api_factory : lusid.SyncApiClientFactory
436
+ The api factory to use
437
+ property_batch : list[lusid.models.UpsertInstrumentPropertyRequest]
438
+ Properties to add,
439
+ identifiers will be resolved to a LusidInstrumentId, where an identifier resolves to more
440
+ than one LusidInstrumentId the property will be added to all matching instruments
441
+ kwargs
442
+
443
+ Returns
444
+ -------
445
+ list[lusid.models.UpsertInstrumentPropertiesResponse]
446
+ the response from LUSID
447
+ """
448
+
449
+ results = []
450
+ for request in property_batch:
451
+ search_request = lusid.models.InstrumentSearchProperty(
452
+ key=f"instrument/default/{request.identifier_type}",
453
+ value=request.identifier,
454
+ )
455
+
456
+ # find the matching instruments
457
+ mastered_instruments = api_factory.build(
458
+ lusid.api.SearchApi
459
+ ).instruments_search(
460
+ instrument_search_property=[search_request], mastered_only=True
461
+ )
462
+
463
+ # flat map the results to a list of luids
464
+ luids = [
465
+ luid
466
+ for luids in [
467
+ list(
468
+ map(
469
+ lambda m: m.identifiers["LusidInstrumentId"].value,
470
+ mastered.mastered_instruments,
471
+ )
472
+ )
473
+ for mastered in [matches for matches in mastered_instruments]
474
+ ]
475
+ for luid in luids
476
+ ]
477
+
478
+ if len(luids) == 0:
479
+ continue
480
+
481
+ properties_request = [
482
+ lusid.models.UpsertInstrumentPropertyRequest(
483
+ identifier_type="LusidInstrumentId",
484
+ identifier=luid,
485
+ properties=request.properties,
486
+ )
487
+ for luid in luids
488
+ ]
489
+
490
+ results.append(
491
+ api_factory.build(
492
+ lusid.api.InstrumentsApi
493
+ ).upsert_instruments_properties(properties_request)
494
+ )
495
+
496
+ return results
497
+
498
+ @staticmethod
499
+ @run_in_executor
500
+ def load_portfolio_group_batch(
501
+ api_factory: lusid.SyncApiClientFactory,
502
+ portfolio_group_batch: list,
503
+ **kwargs,
504
+ ) -> lusid.models.PortfolioGroup:
505
+ """
506
+ Upserts a batch of portfolios to LUSID
507
+
508
+ Parameters
509
+ ----------
510
+ api_factory : lusid.SyncApiClientFactory
511
+ the api factory to use
512
+ portfolio_group_batch : list[lusid.models.CreateTransactionPortfolioRequest]
513
+ The batch of portfilios to create
514
+ scope : str
515
+ The scope to create the portfolio group in
516
+ code : str
517
+ The code of the portfolio group to create
518
+ kwargs
519
+
520
+ Returns
521
+ -------
522
+ lusid.models.PortfolioGroup
523
+ The response from LUSID
524
+ """
525
+
526
+ updated_request = group_request_into_one(
527
+ portfolio_group_batch[0].__class__.__name__,
528
+ portfolio_group_batch,
529
+ ["values"],
530
+ )
531
+
532
+ if "scope" not in list(kwargs.keys()):
533
+ raise KeyError(
534
+ "You are trying to load a portfolio group without a scope, please ensure that a scope is provided."
535
+ )
536
+
537
+ if "code" not in list(kwargs.keys()):
538
+ raise KeyError(
539
+ "You are trying to load a portfolio group without a portfolio code, please ensure that a code is provided."
540
+ )
541
+
542
+ try:
543
+
544
+ current_portfolio_group = api_factory.build(
545
+ lusid.api.PortfolioGroupsApi
546
+ ).get_portfolio_group(scope=kwargs["scope"], code=kwargs["code"])
547
+
548
+ # Capture all portfolios - the ones currently in group + the new ones to be added
549
+ all_portfolios_to_add = (
550
+ updated_request.values + current_portfolio_group.portfolios
551
+ )
552
+
553
+ current_portfolios_in_group = [
554
+ code
555
+ for code in updated_request.values
556
+ if code in current_portfolio_group.portfolios
557
+ ]
558
+
559
+ if len(current_portfolios_in_group) > 0:
560
+ for code in current_portfolios_in_group:
561
+ logging.info(
562
+ f"The portfolio {code.code} with scope {code.scope} is already in group {current_portfolio_group.id.code}"
563
+ )
564
+
565
+ # Parse out new portfolios only
566
+ new_portfolios = [
567
+ code
568
+ for code in all_portfolios_to_add
569
+ if code not in current_portfolio_group.portfolios
570
+ ]
571
+
572
+ for code, scope in set(
573
+ [(resource.code, resource.scope) for resource in new_portfolios]
574
+ ):
575
+
576
+ try:
577
+
578
+ current_portfolio_group = api_factory.build(
579
+ lusid.api.PortfolioGroupsApi
580
+ ).add_portfolio_to_group(
581
+ scope=kwargs["scope"],
582
+ code=kwargs["code"],
583
+ effective_at=datetime.now(tz=pytz.UTC).isoformat(),
584
+ resource_id=lusid.models.ResourceId(scope=scope, code=code),
585
+ )
586
+
587
+ except lusid.exceptions.ApiException as e:
588
+ logging.error(json.loads(e.body)["title"])
589
+
590
+ return current_portfolio_group
591
+
592
+ # Add in here upsert portfolio properties if it does exist
593
+ except lusid.exceptions.ApiException as e:
594
+ if e.status == 404:
595
+ return api_factory.build(
596
+ lusid.api.PortfolioGroupsApi
597
+ ).create_portfolio_group(
598
+ scope=kwargs["scope"],
599
+ create_portfolio_group_request=updated_request,
600
+ )
601
+ else:
602
+ return e
603
+
604
+
605
+ async def _load_data(
606
+ api_factory: lusid.SyncApiClientFactory,
607
+ single_requests: list,
608
+ file_type: str,
609
+ **kwargs,
610
+ ):
611
+ """
612
+ This function calls the appropriate batch loader
613
+
614
+ Parameters
615
+ ----------
616
+ api_factory : lusid.SyncApiClientFactory
617
+ The api factory to use
618
+ single_requests
619
+ The list of single requests for LUSID
620
+ file_type : str
621
+ The file type e.g. instruments, portfolios etc.
622
+ kwargs
623
+ arguments specific to each call e.g. effective_at for holdings
624
+
625
+ Returns
626
+ -------
627
+ BatchLoader : StaticMethod
628
+ A static method on batchloader
629
+ """
630
+
631
+ # Dynamically call the correct async function to use based on the file type
632
+ identifier = uuid.uuid4()
633
+ logging.debug(f"Running load_{file_type}_batch({identifier})")
634
+ from time import time
635
+
636
+ start = time()
637
+ response = await getattr(BatchLoader, f"load_{file_type}_batch")(
638
+ api_factory,
639
+ single_requests,
640
+ # Any specific arguments e.g. 'code' for transactions, 'effective_at' for holdings is passed in via **kwargs
641
+ **kwargs,
642
+ )
643
+ logging.debug(f"Batch completed ({identifier}) - duration: {time() - start}")
644
+ return response
645
+
646
+
647
+ def _convert_batch_to_models(
648
+ data_frame: pd.DataFrame,
649
+ mapping_required: dict,
650
+ mapping_optional: dict,
651
+ property_columns: list,
652
+ properties_scope: str,
653
+ instrument_identifier_mapping: dict,
654
+ file_type: str,
655
+ domain_lookup: dict,
656
+ sub_holding_keys: list,
657
+ sub_holding_keys_scope: str,
658
+ **kwargs,
659
+ ):
660
+ """
661
+ This function populates the required models from a DataFrame and loads the data into LUSID
662
+
663
+ Parameters
664
+ ----------
665
+ data_frame : pd.DataFrame
666
+ The DataFrame containing the data to load
667
+ mapping_required : dict
668
+ The required mapping
669
+ mapping_optional : dict
670
+ The optional mapping
671
+ property_columns : list
672
+ The property columns to add as property values
673
+ properties_scope : str
674
+ The scope to add the property values in
675
+ instrument_identifier_mapping : dict
676
+ The mapping for the identifiers
677
+ file_type : str
678
+ The file type to load
679
+ domain_lookup : dict
680
+ The domain lookup
681
+ sub_holding_keys : list
682
+ The sub holding keys to use
683
+ sub_holding_keys_scope : str
684
+ The scope to use for the sub holding keys
685
+ kwargs
686
+ Arguments specific to each call e.g. effective_at for holdings
687
+
688
+ Returns
689
+ -------
690
+ single_requests : list
691
+ A list of populated LUSID request models
692
+ """
693
+
694
+ source_columns = [
695
+ column.get("target", column.get("source")) for column in property_columns
696
+ ]
697
+
698
+ # Get the data types of the columns to be added as properties
699
+ property_dtypes = data_frame.loc[:, source_columns].dtypes
700
+
701
+ # Get the types of the attributes on the top level model for this request
702
+ open_api_types = get_attributes_and_types(getattr(
703
+ lusid.models, domain_lookup[file_type]["top_level_model"]
704
+ ))
705
+
706
+ # If there is a sub_holding_keys attribute and it has a dict type this means the sub_holding_keys
707
+ # need to be populated with property values
708
+ if (
709
+ "sub_holding_keys" in open_api_types.keys()
710
+ and "Mapping" in open_api_types["sub_holding_keys"]
711
+ ):
712
+ sub_holding_key_dtypes = data_frame.loc[:, sub_holding_keys].dtypes
713
+ # If not and they are provided as full keys
714
+ elif len(sub_holding_keys) > 0:
715
+ sub_holding_keys_row = cocoon.properties._infer_full_property_keys(
716
+ partial_keys=sub_holding_keys,
717
+ properties_scope=sub_holding_keys_scope,
718
+ domain="Transaction",
719
+ )
720
+ # If no keys
721
+ else:
722
+ sub_holding_keys_row = None
723
+
724
+ unique_identifiers = kwargs["unique_identifiers"]
725
+
726
+ # Iterate over the DataFrame creating the single requests
727
+ single_requests = []
728
+ for index, row in data_frame.iterrows():
729
+
730
+ # Create the property values for this row
731
+ if domain_lookup[file_type]["domain"] is None:
732
+ properties = None
733
+ else:
734
+ column_to_scope = {
735
+ column.get("target", column.get("source")): column.get(
736
+ "scope", properties_scope
737
+ )
738
+ for column in property_columns
739
+ }
740
+
741
+ properties = cocoon.properties.create_property_values(
742
+ row=row,
743
+ column_to_scope=column_to_scope,
744
+ scope=properties_scope,
745
+ domain=domain_lookup[file_type]["domain"],
746
+ dtypes=property_dtypes,
747
+ )
748
+
749
+ # Create the sub-holding-keys for this row
750
+ if (
751
+ "sub_holding_keys" in open_api_types.keys()
752
+ and "Mapping" in open_api_types["sub_holding_keys"]
753
+ ):
754
+ sub_holding_keys_row = cocoon.properties.create_property_values(
755
+ row=row,
756
+ column_to_scope={},
757
+ scope=sub_holding_keys_scope,
758
+ domain="Transaction",
759
+ dtypes=sub_holding_key_dtypes,
760
+ )
761
+
762
+ # Create identifiers for this row if applicable
763
+ if instrument_identifier_mapping is None or not bool(
764
+ instrument_identifier_mapping
765
+ ):
766
+ identifiers = None
767
+ else:
768
+ identifiers = cocoon.instruments.create_identifiers(
769
+ index=index,
770
+ row=row,
771
+ file_type=file_type,
772
+ instrument_identifier_mapping=instrument_identifier_mapping,
773
+ unique_identifiers=unique_identifiers,
774
+ full_key_format=kwargs["full_key_format"],
775
+ )
776
+
777
+ # Construct the from the mapping, properties and identifiers the single request object and add it to the list
778
+ single_requests.append(
779
+ cocoon.utilities.populate_model(
780
+ model_object_name=domain_lookup[file_type]["top_level_model"],
781
+ required_mapping=mapping_required,
782
+ optional_mapping=mapping_optional,
783
+ row=row,
784
+ properties=properties,
785
+ identifiers=identifiers,
786
+ sub_holding_keys=sub_holding_keys_row,
787
+ )
788
+ )
789
+
790
+ return single_requests
791
+
792
+
793
+ async def _construct_batches(
794
+ api_factory: lusid.SyncApiClientFactory,
795
+ data_frame: pd.DataFrame,
796
+ mapping_required: dict,
797
+ mapping_optional: dict,
798
+ property_columns: list,
799
+ properties_scope: str,
800
+ instrument_identifier_mapping: dict,
801
+ batch_size: int,
802
+ file_type: str,
803
+ domain_lookup: dict,
804
+ sub_holding_keys: list,
805
+ sub_holding_keys_scope: str,
806
+ return_unmatched_items: bool,
807
+ **kwargs,
808
+ ):
809
+ """
810
+ This constructs the batches and asynchronously sends them to be loaded into LUSID
811
+
812
+ Parameters
813
+ ----------
814
+ api_factory : lusid.SyncApiClientFactory
815
+ The api factory to use
816
+ data_frame : pd.DataFrame
817
+ The DataFrame containing the data to load
818
+ mapping_required : dict
819
+ The required mapping
820
+ mapping_optional : dict
821
+ The optional mapping
822
+ property_columns : list
823
+ The property columns to add as property values
824
+ properties_scope : str
825
+ The scope to add the property values in
826
+ instrument_identifier_mapping : dict
827
+ The mapping for the identifiers
828
+ batch_size : int
829
+ The batch size to use
830
+ file_type : str
831
+ The file type to load
832
+ domain_lookup : dict
833
+ The domain lookup
834
+ sub_holding_keys : list
835
+ The sub holding keys to use
836
+ sub_holding_keys_scope : str
837
+ The scope to use for the sub-holding keys
838
+ return_unmatched_items : bool
839
+ Whether items with unmatched identifiers should be returned for transaction or holding upserts
840
+ kwargs
841
+ Arguments specific to each call e.g. effective_at for holdings
842
+
843
+ Returns
844
+ -------
845
+ dict
846
+ Contains the success responses and the errors (where an API exception has been raised)
847
+ """
848
+
849
+ # Get the different behaviours required for different entities e.g quotes can be batched without worrying about portfolios
850
+ batching_no_portfolios = [
851
+ file_type
852
+ for file_type, settings in domain_lookup.items()
853
+ if not settings["portfolio_specific"]
854
+ ]
855
+ batching_with_portfolios = [
856
+ file_type
857
+ for file_type, settings in domain_lookup.items()
858
+ if settings["portfolio_specific"]
859
+ ]
860
+
861
+ if file_type in batching_no_portfolios:
862
+
863
+ # Everything can be sent up asynchronously, prepare batches based on batch size alone
864
+ async_batches = [
865
+ data_frame.iloc[i: i + batch_size]
866
+ for i in range(0, len(data_frame), batch_size)
867
+ ]
868
+
869
+ # Nest the async batches inside a single synchronous batch
870
+ sync_batches = [
871
+ {
872
+ "async_batches": async_batches,
873
+ "codes": [None] * len(async_batches),
874
+ "effective_at": [None] * len(async_batches),
875
+ }
876
+ ]
877
+
878
+ elif file_type in batching_with_portfolios:
879
+
880
+ if "effective_at" in domain_lookup[file_type]["required_call_attributes"]:
881
+
882
+ # Get unique effective dates
883
+ unique_effective_dates = list(
884
+ data_frame[mapping_required["effective_at"]].unique()
885
+ )
886
+
887
+ # Create a group for each effective date as they can not be batched asynchronously
888
+ effective_at_groups = [
889
+ data_frame.loc[
890
+ data_frame[mapping_required["effective_at"]] == effective_at
891
+ ]
892
+ for effective_at in unique_effective_dates
893
+ ]
894
+
895
+ # Create a synchronous batch for each effective date
896
+ sync_batches = [
897
+ {
898
+ # Different portfolio codes can be batched asynchronously inside the synchronous batch
899
+ "async_batches": [
900
+ effective_at_group.loc[
901
+ data_frame[mapping_required["code"]] == code
902
+ ]
903
+ for code in list(
904
+ effective_at_group[mapping_required["code"]].unique()
905
+ )
906
+ ],
907
+ "codes": list(
908
+ effective_at_group[mapping_required["code"]].unique()
909
+ ),
910
+ "effective_at": [
911
+ list(
912
+ effective_at_group[
913
+ mapping_required["effective_at"]
914
+ ].unique()
915
+ )[0]
916
+ ]
917
+ * len(list(effective_at_group[mapping_required["code"]].unique())),
918
+ }
919
+ for effective_at_group in effective_at_groups
920
+ ]
921
+
922
+ else:
923
+
924
+ unique_portfolios = list(data_frame[mapping_required["code"]].unique())
925
+
926
+ # Different portfolio codes can be batched asynchronously
927
+ async_batches = [
928
+ data_frame.loc[data_frame[mapping_required["code"]] == code]
929
+ for code in unique_portfolios
930
+ ]
931
+
932
+ # Inside the synchronous batch split the values for each portfolio into appropriate batch sizes
933
+ sync_batches = [
934
+ {
935
+ "async_batches": [
936
+ async_batch.iloc[i: i + batch_size]
937
+ for async_batch in async_batches
938
+ ],
939
+ "codes": [str(code) for code in unique_portfolios],
940
+ "effective_at": [None] * len(async_batches),
941
+ }
942
+ for i in range(
943
+ 0,
944
+ max([len(async_batch) for async_batch in async_batches]),
945
+ batch_size,
946
+ )
947
+ ]
948
+
949
+ logging.debug("Created sync batches: ")
950
+ logging.debug(
951
+ f"Number of batches: {len(sync_batches)}, "
952
+ + f"Number of items in batches: {sum([len(sync_batch['async_batches']) for sync_batch in sync_batches])}"
953
+ )
954
+
955
+ # Asynchronously load the data into LUSID
956
+ responses = [
957
+ await asyncio.gather(
958
+ *[
959
+ _load_data(
960
+ api_factory=api_factory,
961
+ single_requests=_convert_batch_to_models(
962
+ data_frame=async_batch,
963
+ mapping_required=mapping_required,
964
+ mapping_optional=mapping_optional,
965
+ property_columns=property_columns,
966
+ properties_scope=properties_scope,
967
+ instrument_identifier_mapping=instrument_identifier_mapping,
968
+ file_type=file_type,
969
+ domain_lookup=domain_lookup,
970
+ sub_holding_keys=sub_holding_keys,
971
+ sub_holding_keys_scope=sub_holding_keys_scope,
972
+ **kwargs,
973
+ ),
974
+ file_type=file_type,
975
+ code=code,
976
+ effective_at=effective_at,
977
+ **kwargs,
978
+ )
979
+ for async_batch, code, effective_at in zip(
980
+ sync_batch["async_batches"],
981
+ sync_batch["codes"],
982
+ sync_batch["effective_at"],
983
+ )
984
+ if not async_batch.empty
985
+ ],
986
+ return_exceptions=True,
987
+ )
988
+ for sync_batch in sync_batches
989
+ ]
990
+ logging.debug("Flattening responses")
991
+ responses_flattened = [
992
+ response for responses_sub in responses for response in responses_sub
993
+ ]
994
+
995
+ # Raise any internal exceptions rather than propagating them to the response
996
+ for response in responses_flattened:
997
+ if isinstance(response, Exception) and not isinstance(
998
+ response, lusid.exceptions.ApiException
999
+ ):
1000
+ raise response
1001
+
1002
+ # Collects the exceptions as failures and successful calls as values
1003
+ returned_response = {
1004
+ "errors": [r for r in responses_flattened if isinstance(r, Exception)],
1005
+ "success": [r for r in responses_flattened if not isinstance(r, Exception)],
1006
+ }
1007
+
1008
+ # For successful transactions or holdings file types, optionally return unmatched identifiers with the responses
1009
+ if check_for_unmatched_items(
1010
+ flag=return_unmatched_items,
1011
+ file_type=file_type,
1012
+ ):
1013
+ logging.debug("returning unmatched identifiers with the responses")
1014
+ returned_response["unmatched_items"] = unmatched_items(
1015
+ api_factory=api_factory,
1016
+ scope=kwargs.get("scope", None),
1017
+ data_frame=data_frame,
1018
+ mapping_required=mapping_required,
1019
+ file_type=file_type,
1020
+ returned_response=returned_response,
1021
+ sync_batches=sync_batches,
1022
+ )
1023
+
1024
+ return returned_response
1025
+
1026
+
1027
+ def check_for_unmatched_items(flag, file_type):
1028
+ """
1029
+ This method contains the conditional logic to determine whether the unmatched_items validation should be run.
1030
+ It should not be run if:
1031
+ a) it was not requested
1032
+ b) the upload was not for transactions or holdings
1033
+
1034
+ Parameters
1035
+ ----------
1036
+ flag : bool
1037
+ The bool flag that indicates whether any unmatched identifiers should be checked
1038
+ file_type : str
1039
+ The file type of the upload
1040
+ Returns
1041
+ -------
1042
+
1043
+ A boolean value indicating whether the unmatched_items validation should be completed
1044
+ """
1045
+ condition_1 = flag is True
1046
+ condition_2 = file_type in ["transaction", "holding"]
1047
+
1048
+ return condition_1 and condition_2
1049
+
1050
+
1051
+ @checkargs
1052
+ def unmatched_items(
1053
+ api_factory: lusid.SyncApiClientFactory,
1054
+ scope: str,
1055
+ data_frame: pd.DataFrame,
1056
+ mapping_required: dict,
1057
+ file_type: str,
1058
+ returned_response: dict,
1059
+ sync_batches: list = None,
1060
+ ):
1061
+ """
1062
+ This method orchestrates the identification of holdings or transactions objects that were successfully uploaded
1063
+ but did not resolve to known identifiers (i.e. where the instrument_uid is LUID_ZZZZZZ).
1064
+
1065
+ If there were any errors in the attempted upload, the check will not complete and an error message will be returned
1066
+ to let the user know that errors will need to be resolved first.
1067
+
1068
+ Parameters
1069
+ ----------
1070
+ api_factory : lusid.SyncApiClientFactory api_factory
1071
+ The api factory to use
1072
+ scope : str
1073
+ The scope of the resource to load the data into
1074
+ data_frame : pd.DataFrame
1075
+ The DataFrame containing the data
1076
+ mapping_required : dict
1077
+ The required mapping
1078
+ file_type : str
1079
+ The type of file e.g. transactions, instruments, holdings, quotes, portfolios
1080
+ returned_response : dict
1081
+ The response from laod_from_data_frame
1082
+ sync_batches : list
1083
+ A list of the batches used to upload the data into LUSID.
1084
+
1085
+ Returns
1086
+ -------
1087
+ responses: list
1088
+ A list of objects to be appended to the ultimate response for load_from_data_frame.
1089
+ """
1090
+
1091
+ if len(returned_response["errors"]) > 0:
1092
+ return ["Please resolve all upload errors to check for unmatched items."]
1093
+
1094
+ if file_type == "transaction":
1095
+ return _unmatched_transactions(
1096
+ api_factory=api_factory,
1097
+ scope=scope,
1098
+ data_frame=data_frame,
1099
+ mapping_required=mapping_required,
1100
+ sync_batches=sync_batches,
1101
+ )
1102
+ elif file_type == "holding":
1103
+ return _unmatched_holdings(
1104
+ api_factory=api_factory,
1105
+ scope=scope,
1106
+ sync_batches=sync_batches,
1107
+ )
1108
+
1109
+
1110
+ def _unmatched_transactions(
1111
+ api_factory: lusid.SyncApiClientFactory,
1112
+ scope: str,
1113
+ data_frame: pd.DataFrame,
1114
+ mapping_required: dict,
1115
+ sync_batches: list = None,
1116
+ ):
1117
+ """
1118
+ This method identifies which instruments were not resolved with a transaction upload using load_from_data_frame.
1119
+
1120
+ Parameters
1121
+ ----------
1122
+ api_factory : lusid.SyncApiClientFactory api_factory
1123
+ The api factory to use
1124
+ scope : str
1125
+ The scope of the resource to load the data into
1126
+ data_frame : pd.DataFrame
1127
+ The DataFrame containing the data
1128
+ sync_batches : list
1129
+ A list of the batches used to upload the data into LUSID.
1130
+
1131
+ Returns
1132
+ -------
1133
+ responses: list
1134
+ A list of transaction objects to be appended to the ultimate response for load_from_data_frame.
1135
+ """
1136
+ # Extract a list of portfolio codes from the sync_batches
1137
+ portfolio_codes = extract_unique_portfolio_codes(sync_batches)
1138
+
1139
+ # Create empty list to hold transaction ids and instruments
1140
+ unmatched_transactions = []
1141
+
1142
+ # For each portfolio, request the unmatched transactions from LUSID and append to the instantiated list
1143
+ for portfolio_code in portfolio_codes:
1144
+ portfolio_transactions = data_frame.loc[
1145
+ data_frame[mapping_required["code"]] == portfolio_code
1146
+ ]
1147
+ from_transaction_date = min(
1148
+ portfolio_transactions[mapping_required["transaction_date"]].apply(
1149
+ lambda x: str(DateOrCutLabel(x))
1150
+ )
1151
+ )
1152
+ to_transactions_date = max(
1153
+ portfolio_transactions[mapping_required["transaction_date"]].apply(
1154
+ lambda x: str(DateOrCutLabel(x))
1155
+ )
1156
+ )
1157
+
1158
+ unmatched_transactions.extend(
1159
+ return_unmatched_transactions(
1160
+ api_factory=api_factory,
1161
+ scope=scope,
1162
+ code=portfolio_code,
1163
+ from_transaction_date=from_transaction_date,
1164
+ to_transaction_date=to_transactions_date,
1165
+ )
1166
+ )
1167
+
1168
+ # With the upload dataframe, filter out any transactions from the list that were not part of this upload and return
1169
+ return filter_unmatched_transactions(
1170
+ data_frame=data_frame,
1171
+ mapping_required=mapping_required,
1172
+ unmatched_transactions=unmatched_transactions,
1173
+ )
1174
+
1175
+
1176
+ def return_unmatched_transactions(
1177
+ api_factory: lusid.SyncApiClientFactory,
1178
+ scope: str,
1179
+ code: str,
1180
+ from_transaction_date: str,
1181
+ to_transaction_date: str,
1182
+ ):
1183
+ """
1184
+ Call the get transactions api and only return those transactions with unresolved identifiers.
1185
+
1186
+ Multiple pages of responses for the get_transactions api call are handled.
1187
+
1188
+ Parameters
1189
+ ----------
1190
+ api_factory : lusid.SyncApiClientFactory api_factory
1191
+ The api factory to use
1192
+ scope : str
1193
+ The scope of the resource to load the data into
1194
+ code : str
1195
+ The code of the portfolio containing the transactions we want to return
1196
+
1197
+ Returns
1198
+ -------
1199
+ A list of transaction objects with the structure.
1200
+ """
1201
+ transactions_api = api_factory.build(lusid.api.TransactionPortfoliosApi)
1202
+ done = False
1203
+ next_page = None
1204
+ unmatched_transactions = []
1205
+
1206
+ # We need to handle the possibility of paginated results
1207
+ while not done:
1208
+
1209
+ # There must be a filter included so only transactions with unmatched instruments are returned
1210
+ kwargs = {
1211
+ "scope": scope,
1212
+ "code": code,
1213
+ "from_transaction_date": from_transaction_date,
1214
+ "to_transaction_date": to_transaction_date,
1215
+ "filter": "instrumentUid eq'LUID_ZZZZZZZZ'",
1216
+ }
1217
+
1218
+ if next_page is not None:
1219
+ kwargs["page"] = next_page
1220
+
1221
+ response = transactions_api.get_transactions(**kwargs)
1222
+
1223
+ unmatched_transactions.extend([response for response in response.values])
1224
+
1225
+ next_page = response.next_page
1226
+ done = response.next_page is None
1227
+
1228
+ return unmatched_transactions
1229
+
1230
+
1231
+ def filter_unmatched_transactions(
1232
+ data_frame: pd.DataFrame,
1233
+ mapping_required: dict,
1234
+ unmatched_transactions: list,
1235
+ ):
1236
+ """
1237
+ This method will take the full list of unmatched transactions and remove any transactions that were not
1238
+ part of the current upload with load_from_data_frame.
1239
+
1240
+ Parameters
1241
+ ----------
1242
+ data_frame : pd.DataFrame
1243
+ The DataFrame containing the data
1244
+ mapping_required : dict
1245
+ The required mapping from load_from_data_frame that helps identify the transaction_id column in the DataFrame
1246
+ unmatched_transactions : list
1247
+ A list of transaction objects.
1248
+
1249
+ Returns
1250
+ -------
1251
+ A filtered list of transaction objects.
1252
+ """
1253
+ # Create a unique list of transaction_ids from the dataframe
1254
+ valid_txids = set(data_frame[mapping_required.get("transaction_id")])
1255
+
1256
+ # Iterate through the transactions and if the transaction_id is in the set, add it to the list to be returned
1257
+ filtered_unmatched_transactions = [
1258
+ unmatched_transaction
1259
+ for unmatched_transaction in unmatched_transactions
1260
+ if unmatched_transaction.transaction_id in valid_txids
1261
+ ]
1262
+
1263
+ return filtered_unmatched_transactions
1264
+
1265
+
1266
+ def _unmatched_holdings(
1267
+ api_factory: lusid.SyncApiClientFactory,
1268
+ scope: str,
1269
+ sync_batches: list = None,
1270
+ ):
1271
+ """
1272
+ This method identifies which instruments were not resolved with a holdings upload using load_from_data_frame.
1273
+
1274
+ Parameters
1275
+ ----------
1276
+ api_factory : lusid.SyncApiClientFactory api_factory
1277
+ The api factory to use
1278
+ scope : str
1279
+ The scope of the resource to load the data into
1280
+ sync_batches : list
1281
+ A list of the batches used to upload the data into LUSID
1282
+
1283
+ Returns
1284
+ -------
1285
+ responses: list
1286
+ A list of holding objects to be appended to the ultimate response for load_from_data_frame.
1287
+ """
1288
+ # Extract a list of tuples of portfolio codes and effective at times from sync_batches
1289
+ code_tuples = extract_unique_portfolio_codes_effective_at_tuples(sync_batches)
1290
+
1291
+ # Create empty list to hold holdings that have not been resolved
1292
+ unmatched_holdings = []
1293
+
1294
+ # For each holding adjustment in the upload, check whether any contained unresolved instruments and append to list
1295
+ for code_tuple in code_tuples:
1296
+ unmatched_holdings.extend(
1297
+ return_unmatched_holdings(
1298
+ api_factory=api_factory,
1299
+ scope=scope,
1300
+ code_tuple=code_tuple,
1301
+ )
1302
+ )
1303
+
1304
+ return unmatched_holdings
1305
+
1306
+
1307
+ def return_unmatched_holdings(
1308
+ api_factory: lusid.SyncApiClientFactory,
1309
+ scope: str,
1310
+ code_tuple: Tuple[str, str],
1311
+ ):
1312
+ """
1313
+ Call the get holdings adjustments api and return a list of holding objects that have unresolved identifiers.
1314
+
1315
+ Parameters
1316
+ ----------
1317
+ api_factory : lusid.SyncApiClientFactory api_factory
1318
+ The api factory to use
1319
+ scope : str
1320
+ The scope of the resource to load the data into
1321
+ code_tuple : (str, str)
1322
+ A tuple of format (portfolio_code, effective_at) which represents the combination of values
1323
+ used when upserting holdings during load_from_data_frame for file_type 'holding'
1324
+
1325
+ Returns
1326
+ -------
1327
+ A list of holding objects.
1328
+
1329
+ """
1330
+ transactions_api = api_factory.build(lusid.api.TransactionPortfoliosApi)
1331
+
1332
+ # In case a holdings check occurs for a portfolio code and effective at combination that contain no holdings,
1333
+ # make sure to gracefully handle the exception so that any LUSID error from the main upload is not suppressed.
1334
+ try:
1335
+ response = transactions_api.get_holdings_adjustment(
1336
+ scope=scope,
1337
+ code=code_tuple[0],
1338
+ effective_at=str(DateOrCutLabel(code_tuple[1])),
1339
+ )
1340
+ except lusid.ApiException as e:
1341
+ if "HoldingsAdjustmentDoesNotExist" in str(e.body):
1342
+ logging.info(
1343
+ f"While validating holding upload, LUSID was unable to find any holdings within portfolio "
1344
+ f"{code_tuple[0]} for effectiveAt {str(DateOrCutLabel(code_tuple[1]))}."
1345
+ )
1346
+ return []
1347
+
1348
+ # Create a list of holding objects with unmatched identifiers (e.g. with LUID_ZZZZZZZZ) from the response
1349
+ return [
1350
+ adjustment
1351
+ for adjustment in response.adjustments
1352
+ if adjustment.instrument_uid == "LUID_ZZZZZZZZ"
1353
+ ]
1354
+
1355
+
1356
+ @checkargs
1357
+ def load_from_data_frame(
1358
+ api_factory: lusid.SyncApiClientFactory,
1359
+ scope: str,
1360
+ data_frame: pd.DataFrame,
1361
+ mapping_required: dict,
1362
+ mapping_optional: dict,
1363
+ file_type: str,
1364
+ identifier_mapping: dict = None,
1365
+ property_columns: list = None,
1366
+ properties_scope: str = None,
1367
+ batch_size: int = None,
1368
+ remove_white_space: bool = True,
1369
+ instrument_name_enrichment: bool = False,
1370
+ transactions_commit_mode: str = None,
1371
+ sub_holding_keys: list = None,
1372
+ holdings_adjustment_only: bool = False,
1373
+ thread_pool_max_workers: int = 5,
1374
+ sub_holding_keys_scope: str = None,
1375
+ return_unmatched_items: bool = False,
1376
+ instrument_scope: str = None,
1377
+ ):
1378
+ """
1379
+
1380
+ Parameters
1381
+ ----------
1382
+ api_factory : lusid.SyncApiClientFactory api_factory
1383
+ The api factory to use
1384
+ scope : str
1385
+ The scope of the resource to load the data into
1386
+ If file_type="instrument" scope will only be used for instrument properties
1387
+ data_frame : pd.DataFrame
1388
+ The DataFrame containing the data
1389
+ mapping_required : dict{str, str}
1390
+ The dictionary mapping the DataFrame columns to LUSID's required attributes
1391
+ mapping_optional : dict{str, str}
1392
+ The dictionary mapping the DataFrame columns to LUSID's optional attributes
1393
+ file_type : str
1394
+ The type of file e.g. transactions, instruments, holdings, quotes, portfolios
1395
+ identifier_mapping : dict{str, str}
1396
+ The dictionary mapping of LUSID instrument identifiers to identifiers in the DataFrame
1397
+ property_columns : list
1398
+ The columns to create properties for
1399
+ properties_scope : str
1400
+ The scope to add the properties to
1401
+ batch_size : int
1402
+ The size of the batch to use when using upsert calls e.g. upsert instruments, upsert quotes etc.
1403
+ remove_white_space : bool
1404
+ remove whitespace either side of each value in the dataframe
1405
+ instrument_name_enrichment : bool
1406
+ request additional identifier information from open-figi
1407
+ sub_holding_keys : list
1408
+ The sub holding keys to use for this request. Can be a list of property keys or a list of
1409
+ columns in the dataframe to use to create sub holdings
1410
+ holdings_adjustment_only : bool
1411
+ Whether to use the adjust_holdings api call rather than set_holdings when working with holdings
1412
+ thread_pool_max_workers : int
1413
+ The maximum number of workers to use in the thread pool used by the function
1414
+ sub_holding_keys_scope : str
1415
+ The scope to add the sub-holding keys to
1416
+ return_unmatched_items : bool
1417
+ When loading transactions or holdings, a 'True' flag will return a list of the transaction or holding
1418
+ objects where their instruments were unmatched at the time of the upsert
1419
+ (i.e. where instrument_uid is LUID_ZZZZZZ). This parameter will be ignored for file types other than
1420
+ transactions or holdings
1421
+ instrument_scope : str
1422
+ The scope to upsert to when upseting instrument
1423
+
1424
+ Returns
1425
+ -------
1426
+ responses: dict
1427
+ The responses from loading the data into LUSID
1428
+
1429
+ Examples
1430
+ --------
1431
+
1432
+ * Loading Instruments
1433
+
1434
+ .. code-block:: none
1435
+
1436
+ result = finbourne_sdk_utils.cocoon.load_from_data_frame(
1437
+ api_factory=api_factory,
1438
+ scope=scope,
1439
+ data_frame=instr_df,
1440
+ mapping_required=mapping["instruments"]["required"],
1441
+ mapping_optional={},
1442
+ file_type="instruments",
1443
+ identifier_mapping=mapping["instruments"]["identifier_mapping"],
1444
+ property_columns=mapping["instruments"]["properties"],
1445
+ properties_scope=scope,
1446
+ instrument_scope=scope
1447
+ )
1448
+
1449
+ * Loading Instrument Properties
1450
+
1451
+ .. code-block:: none
1452
+
1453
+ result = finbourne_sdk_utils.cocoon.load_from_data_frame(
1454
+ api_factory=api_factory,
1455
+ scope=scope,
1456
+ data_frame=strat_properties,
1457
+ mapping_required=strat_mapping,
1458
+ mapping_optional={},
1459
+ file_type="instrument_property",
1460
+ property_columns=["block tag"],
1461
+ properties_scope=scope
1462
+ )
1463
+
1464
+ * Loading Portfolios
1465
+
1466
+ .. code-block:: none
1467
+
1468
+ result = finbourne_sdk_utils.cocoon.load_from_data_frame(
1469
+ api_factory=api_factory,
1470
+ scope=scope,
1471
+ data_frame=portfolios,
1472
+ mapping_required=mapping["portfolios"]["required"],
1473
+ mapping_optional={},
1474
+ file_type="portfolios"
1475
+ )
1476
+
1477
+ * Loading Transactions
1478
+
1479
+ .. code-block:: none
1480
+
1481
+ result = finbourne_sdk_utils.cocoon.load_from_data_frame(
1482
+ api_factory=api_factory,
1483
+ scope=scope,
1484
+ data_frame=txn_df,
1485
+ mapping_required=mapping["transactions"]["required"],
1486
+ mapping_optional=mapping["transactions"]["optional"],
1487
+ file_type="transactions",
1488
+ identifier_mapping=mapping["transactions"]["identifier_mapping"],
1489
+ property_columns=mapping["transactions"]["properties"],
1490
+ properties_scope=scope
1491
+ )
1492
+
1493
+ * Loading Transactions with Commit Mode
1494
+
1495
+ .. code-block:: none
1496
+
1497
+ result = finbourne_sdk_utils.cocoon.load_from_data_frame(
1498
+ api_factory=api_factory,
1499
+ scope=scope,
1500
+ data_frame=txn_df,
1501
+ mapping_required=mapping["transactions"]["required"],
1502
+ mapping_optional=mapping["transactions"]["optional"],
1503
+ file_type="transactions_with_commit",
1504
+ transactions_commit_mode="(Atomic|Partial)"
1505
+ identifier_mapping=mapping["transactions"]["identifier_mapping"],
1506
+ property_columns=mapping["transactions"]["properties"],
1507
+ properties_scope=scope
1508
+ )
1509
+
1510
+
1511
+ * Loading Quotes
1512
+
1513
+ .. code-block:: none
1514
+
1515
+ result = lpt.load_from_data_frame(
1516
+ api_factory=api_factory,
1517
+ scope=scope,
1518
+ data_frame=df_adjusted_quotes,
1519
+ mapping_required=mapping["quotes"]["required"],
1520
+ mapping_optional={},
1521
+ file_type="quotes"
1522
+ )
1523
+
1524
+ * loading Holdings
1525
+
1526
+ .. code-block:: none
1527
+
1528
+ result = lpt.load_from_data_frame(
1529
+ api_factory=api_factory,
1530
+ scope=holdings_scope,
1531
+ data_frame=seg_df,
1532
+ mapping_required=mapping["holdings"]["required"],
1533
+ mapping_optional=mapping["holdings"]["optional"],
1534
+ identifier_mapping=holdings_mapping["holdings"]["identifier_mapping"],
1535
+ file_type="holdings"
1536
+ )
1537
+
1538
+ """
1539
+
1540
+ # A mapping between the file type and relevant attributes e.g. domain, top_level_model etc.
1541
+ domain_lookup = cocoon.utilities.load_json_file("config/domain_settings.json")
1542
+
1543
+ # Convert the file type to lower case & singular as well as checking it is of the allowed value
1544
+ file_type = (
1545
+ Validator(file_type, "file_type")
1546
+ .make_singular()
1547
+ .make_lower()
1548
+ .check_allowed_value(list(domain_lookup.keys()))
1549
+ .value
1550
+ )
1551
+
1552
+ # Ensures that it is a single index dataframe
1553
+ Validator(data_frame.index, "data_frame_index").check_is_not_instance(pd.MultiIndex)
1554
+
1555
+ # Set defaults aligned with the data type of each argument, this allows for users to provide None
1556
+ identifier_mapping = (
1557
+ Validator(identifier_mapping, "identifier_mapping")
1558
+ .set_default_value_if_none(default={})
1559
+ .discard_dict_keys_none_value()
1560
+ .value
1561
+ )
1562
+
1563
+ properties_scope = (
1564
+ Validator(properties_scope, "properties_scope")
1565
+ .set_default_value_if_none(default=scope)
1566
+ .value
1567
+ )
1568
+
1569
+ sub_holding_keys_scope = (
1570
+ Validator(sub_holding_keys_scope, "sub_holding_keys_scope")
1571
+ .set_default_value_if_none(default=properties_scope)
1572
+ .value
1573
+ )
1574
+
1575
+ instrument_scope = (
1576
+ Validator(instrument_scope, "instrument_scope")
1577
+ .set_default_value_if_none(default="default")
1578
+ .value
1579
+ )
1580
+
1581
+ property_columns = (
1582
+ Validator(property_columns, "property_columns")
1583
+ .set_default_value_if_none(default=[])
1584
+ .value
1585
+ )
1586
+
1587
+ property_columns = [
1588
+ {"source": column, "target": column} if isinstance(column, str) else column
1589
+ for column in property_columns
1590
+ ]
1591
+
1592
+ Validator(
1593
+ property_columns, "property_columns"
1594
+ ).check_entries_are_strings_or_dict_containing_key("source")
1595
+
1596
+ sub_holding_keys = (
1597
+ Validator(sub_holding_keys, "sub_holding_keys")
1598
+ .set_default_value_if_none(default=[])
1599
+ .value
1600
+ )
1601
+
1602
+ batch_size = (
1603
+ Validator(batch_size, "batch_size")
1604
+ .set_default_value_if_none(domain_lookup[file_type]["default_batch_size"])
1605
+ .override_value(
1606
+ not domain_lookup[file_type]["batch_allowed"],
1607
+ domain_lookup[file_type]["default_batch_size"],
1608
+ )
1609
+ .value
1610
+ )
1611
+
1612
+ # Discard mappings where the provided value is None
1613
+ mapping_required = (
1614
+ Validator(mapping_required, "mapping_required")
1615
+ .discard_dict_keys_none_value()
1616
+ .value
1617
+ )
1618
+
1619
+ mapping_optional = (
1620
+ Validator(mapping_optional, "mapping_optional")
1621
+ .discard_dict_keys_none_value()
1622
+ .value
1623
+ )
1624
+
1625
+ required_call_attributes = domain_lookup[file_type]["required_call_attributes"]
1626
+ if "scope" in required_call_attributes:
1627
+ required_call_attributes.remove("scope")
1628
+
1629
+ # Check that all required parameters exist
1630
+ Validator(
1631
+ required_call_attributes, "required_attributes_for_call"
1632
+ ).check_subset_of_list(list(mapping_required.keys()), "required_mapping")
1633
+
1634
+ # Verify that all the required attributes for this top level model exist in the provided required mapping
1635
+ cocoon.utilities.verify_all_required_attributes_mapped(
1636
+ mapping=mapping_required,
1637
+ model_object_name=domain_lookup[file_type]["top_level_model"],
1638
+ exempt_attributes=["identifiers", "properties", "instrument_identifiers"],
1639
+ )
1640
+
1641
+ # Create the thread pool to use with the async_tools.run_in_executor decorator to make sync functions awaitable
1642
+ thread_pool = ThreadPool(thread_pool_max_workers).thread_pool
1643
+
1644
+ if instrument_name_enrichment:
1645
+ loop = cocoon.async_tools.start_event_loop_new_thread()
1646
+
1647
+ data_frame, mapping_required = asyncio.run_coroutine_threadsafe(
1648
+ cocoon.instruments.enrich_instruments(
1649
+ api_factory=api_factory,
1650
+ data_frame=data_frame,
1651
+ instrument_identifier_mapping=identifier_mapping,
1652
+ mapping_required=mapping_required,
1653
+ constant_prefix="$",
1654
+ **{"thread_pool": thread_pool},
1655
+ ),
1656
+ loop,
1657
+ ).result()
1658
+
1659
+ # Stop the additional event loop
1660
+ cocoon.async_tools.stop_event_loop_new_thread(loop)
1661
+
1662
+ """
1663
+ Unnest and populate defaults where a mapping is provided with column and/or default fields in a nested dictionary
1664
+
1665
+ e.g.
1666
+ {'name': {
1667
+ 'column': 'instrument_name',
1668
+ 'default': 'unknown_name'
1669
+ }
1670
+ }
1671
+
1672
+ rather than simply
1673
+ {'name': 'instrument_name'}
1674
+ """
1675
+ (
1676
+ data_frame,
1677
+ mapping_required,
1678
+ ) = cocoon.utilities.handle_nested_default_and_column_mapping(
1679
+ data_frame=data_frame, mapping=mapping_required, constant_prefix="$"
1680
+ )
1681
+ (
1682
+ data_frame,
1683
+ mapping_optional,
1684
+ ) = cocoon.utilities.handle_nested_default_and_column_mapping(
1685
+ data_frame=data_frame, mapping=mapping_optional, constant_prefix="$"
1686
+ )
1687
+
1688
+ # Get all the DataFrame columns as well as those that contain at least one null value
1689
+ data_frame_columns = list(data_frame.columns.values)
1690
+ nan_columns = [
1691
+ column for column in data_frame_columns if data_frame[column].isna().any()
1692
+ ]
1693
+
1694
+ # Validate that none of the provided columns are missing or invalid
1695
+ Validator(
1696
+ mapping_required, "mapping_required"
1697
+ ).get_dict_values().filter_list_using_first_character("$").check_subset_of_list(
1698
+ data_frame_columns, "DataFrame Columns"
1699
+ ).check_no_intersection_with_list(
1700
+ nan_columns, "Columns with Missing Values"
1701
+ )
1702
+
1703
+ Validator(
1704
+ mapping_optional, "mapping_optional"
1705
+ ).get_dict_values().filter_list_using_first_character("$").check_subset_of_list(
1706
+ data_frame_columns, "DataFrame Columns"
1707
+ )
1708
+
1709
+ Validator(
1710
+ identifier_mapping, "identifier_mapping"
1711
+ ).get_dict_values().filter_list_using_first_character("$").check_subset_of_list(
1712
+ data_frame_columns, "DataFrame Columns"
1713
+ )
1714
+
1715
+ source_columns = [column["source"] for column in property_columns]
1716
+ Validator(source_columns, "property_columns").check_subset_of_list(
1717
+ data_frame_columns, "DataFrame Columns"
1718
+ )
1719
+
1720
+ # Converts higher level data types such as dictionaries and lists to strings
1721
+ data_frame = data_frame.applymap(cocoon.utilities.convert_cell_value_to_string)
1722
+
1723
+ if remove_white_space:
1724
+ column_list = [source_columns]
1725
+ for col in [mapping_optional, mapping_required, identifier_mapping]:
1726
+ column_list.append(col.values())
1727
+
1728
+ column_list = list(set([item for sublist in column_list for item in sublist]))
1729
+ data_frame = strip_whitespace(data_frame, column_list)
1730
+
1731
+ # Get the types of the attributes on the top level model for this request
1732
+
1733
+ open_api_types = get_attributes_and_types(getattr(
1734
+ lusid.models, domain_lookup[file_type]["top_level_model"]
1735
+ ))
1736
+
1737
+ # If there is a sub_holding_keys attribute and it has a dict type this means the sub_holding_keys
1738
+ # need to have a property definition and be populated with values from the provided dataframe columns
1739
+ if (
1740
+ "sub_holding_keys" in open_api_types.keys()
1741
+ and "Mapping[" in open_api_types["sub_holding_keys"]
1742
+ ):
1743
+ Validator(sub_holding_keys, "sub_holding_key_columns").check_subset_of_list(
1744
+ data_frame_columns, "DataFrame Columns"
1745
+ )
1746
+
1747
+ # Check for and create missing property definitions for the sub-holding-keys
1748
+ data_frame = cocoon.properties.create_missing_property_definitions_from_file(
1749
+ api_factory=api_factory,
1750
+ properties_scope=sub_holding_keys_scope,
1751
+ domain="Transaction",
1752
+ data_frame=data_frame,
1753
+ property_columns=[{"source": key} for key in sub_holding_keys],
1754
+ )
1755
+
1756
+ # Check for and create missing property definitions for the properties
1757
+ if domain_lookup[file_type]["domain"] is not None:
1758
+ data_frame = cocoon.properties.create_missing_property_definitions_from_file(
1759
+ api_factory=api_factory,
1760
+ properties_scope=properties_scope,
1761
+ domain=domain_lookup[file_type]["domain"],
1762
+ data_frame=data_frame,
1763
+ property_columns=property_columns,
1764
+ )
1765
+
1766
+ # If the transaction contains subholding keys which aren't defined in the portfolio. We first create the
1767
+ # properties that don't already exist, then we make the properties sub-holding keys in the portfolios.
1768
+ if (
1769
+ file_type in ("transaction", "transactions_commit_mode")
1770
+ and sub_holding_keys is not None
1771
+ and sub_holding_keys != []
1772
+ ):
1773
+ # if the SHK key is written in {domain}/{scope}/{code} form we extract the code since when we add it to
1774
+ # the properties there will be issues due to different formats. Also, there are issues with the
1775
+ # create_missing_property_definitions_from_file function as it extracts data from columns using the
1776
+ # sub-holding keys.
1777
+ sub_holding_keys_codes = [
1778
+ key if "/" not in key else key.split("/")[2] for key in sub_holding_keys
1779
+ ]
1780
+
1781
+ # Check for and create missing property definitions for the sub-holding-keys
1782
+ data_frame = cocoon.properties.create_missing_property_definitions_from_file(
1783
+ api_factory=api_factory,
1784
+ properties_scope=properties_scope,
1785
+ domain="Transaction",
1786
+ data_frame=data_frame,
1787
+ property_columns=[{"source": key} for key in sub_holding_keys_codes],
1788
+ )
1789
+
1790
+ transaction_portfolio_api = api_factory.build(
1791
+ lusid.api.TransactionPortfoliosApi
1792
+ )
1793
+
1794
+ # Add subholding keys to the portfolios we are going to apply the transactions to
1795
+ for code in set(data_frame[mapping_required["code"]]):
1796
+ transaction_portfolio_api.patch_portfolio_details(
1797
+ scope,
1798
+ code,
1799
+ [
1800
+ {
1801
+ "value": cocoon.properties._infer_full_property_keys(
1802
+ partial_keys=sub_holding_keys,
1803
+ properties_scope=properties_scope,
1804
+ domain="Transaction",
1805
+ ),
1806
+ "path": "/subHoldingKeys",
1807
+ "op": "add",
1808
+ }
1809
+ ],
1810
+ )
1811
+
1812
+ # Add sub-holding keys to the properties, so it is created for each transaction.
1813
+ property_columns += [
1814
+ {"source": sub_holding_key, "target": sub_holding_key}
1815
+ for sub_holding_key in sub_holding_keys_codes
1816
+ ]
1817
+
1818
+ # Start a new event loop in a new thread, this is required to run inside a Jupyter notebook
1819
+ loop = cocoon.async_tools.start_event_loop_new_thread()
1820
+
1821
+ # Keyword arguments to be used in requests to the LUSID API
1822
+ keyword_arguments = {
1823
+ "scope": scope,
1824
+ # This handles that identifiers need to be specified differently based on the request type, allowing users
1825
+ # to provide either the entire key e.g. "Instrument/default/Figi" or just the code "Figi" for any request
1826
+ "full_key_format": domain_lookup[file_type]["full_key_format"],
1827
+ # Gets the allowed unique identifiers
1828
+ "unique_identifiers": cocoon.instruments.get_unique_identifiers(
1829
+ api_factory=api_factory
1830
+ ),
1831
+ "transactions_commit_mode": transactions_commit_mode,
1832
+ "holdings_adjustment_only": holdings_adjustment_only,
1833
+ "thread_pool": thread_pool,
1834
+ "instrument_scope": instrument_scope,
1835
+ }
1836
+
1837
+ # Get the responses from LUSID
1838
+ logging.debug("constructing batches...")
1839
+ responses = asyncio.run_coroutine_threadsafe(
1840
+ _construct_batches(
1841
+ api_factory=api_factory,
1842
+ data_frame=data_frame,
1843
+ mapping_required=mapping_required,
1844
+ mapping_optional=mapping_optional,
1845
+ property_columns=property_columns,
1846
+ properties_scope=properties_scope,
1847
+ instrument_identifier_mapping=identifier_mapping,
1848
+ batch_size=batch_size,
1849
+ file_type=file_type,
1850
+ domain_lookup=domain_lookup,
1851
+ sub_holding_keys=sub_holding_keys,
1852
+ sub_holding_keys_scope=sub_holding_keys_scope,
1853
+ return_unmatched_items=return_unmatched_items,
1854
+ **keyword_arguments,
1855
+ ),
1856
+ loop,
1857
+ ).result()
1858
+
1859
+ # Stop the additional event loop
1860
+ cocoon.async_tools.stop_event_loop_new_thread(loop)
1861
+
1862
+ return {file_type + "s": responses}