wbfdm 1.49.5__py2.py3-none-any.whl → 1.59.4__py2.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 (109) hide show
  1. wbfdm/admin/exchanges.py +1 -1
  2. wbfdm/admin/instruments.py +3 -2
  3. wbfdm/analysis/financial_analysis/change_point_detection.py +88 -0
  4. wbfdm/analysis/financial_analysis/statement_with_estimates.py +5 -6
  5. wbfdm/analysis/financial_analysis/utils.py +6 -0
  6. wbfdm/contrib/dsws/client.py +3 -3
  7. wbfdm/contrib/dsws/dataloaders/market_data.py +31 -3
  8. wbfdm/contrib/internal/dataloaders/market_data.py +43 -9
  9. wbfdm/contrib/metric/backends/base.py +2 -2
  10. wbfdm/contrib/metric/backends/statistics.py +47 -13
  11. wbfdm/contrib/metric/dispatch.py +3 -0
  12. wbfdm/contrib/metric/exceptions.py +1 -1
  13. wbfdm/contrib/metric/filters.py +19 -0
  14. wbfdm/contrib/metric/models.py +6 -0
  15. wbfdm/contrib/metric/orchestrators.py +4 -4
  16. wbfdm/contrib/metric/signals.py +7 -0
  17. wbfdm/contrib/metric/tasks.py +2 -3
  18. wbfdm/contrib/metric/viewsets/configs/display.py +2 -2
  19. wbfdm/contrib/metric/viewsets/mixins.py +6 -6
  20. wbfdm/contrib/msci/client.py +6 -2
  21. wbfdm/contrib/qa/database_routers.py +1 -1
  22. wbfdm/contrib/qa/dataloaders/adjustments.py +2 -1
  23. wbfdm/contrib/qa/dataloaders/corporate_actions.py +2 -1
  24. wbfdm/contrib/qa/dataloaders/financials.py +19 -1
  25. wbfdm/contrib/qa/dataloaders/fx_rates.py +86 -0
  26. wbfdm/contrib/qa/dataloaders/market_data.py +29 -40
  27. wbfdm/contrib/qa/dataloaders/officers.py +1 -1
  28. wbfdm/contrib/qa/dataloaders/statements.py +18 -3
  29. wbfdm/contrib/qa/jinja2/qa/sql/ibes/financials.sql +1 -1
  30. wbfdm/contrib/qa/sync/exchanges.py +2 -1
  31. wbfdm/contrib/qa/sync/utils.py +76 -17
  32. wbfdm/dataloaders/protocols.py +12 -1
  33. wbfdm/dataloaders/proxies.py +15 -1
  34. wbfdm/dataloaders/types.py +7 -1
  35. wbfdm/enums.py +2 -0
  36. wbfdm/factories/instruments.py +4 -2
  37. wbfdm/figures/financials/financial_analysis_charts.py +2 -8
  38. wbfdm/filters/classifications.py +2 -2
  39. wbfdm/filters/financials.py +9 -18
  40. wbfdm/filters/financials_analysis.py +36 -16
  41. wbfdm/filters/instrument_prices.py +8 -5
  42. wbfdm/filters/instruments.py +21 -7
  43. wbfdm/import_export/backends/cbinsights/utils/client.py +8 -8
  44. wbfdm/import_export/backends/refinitiv/utils/controller.py +1 -1
  45. wbfdm/import_export/handlers/instrument.py +160 -104
  46. wbfdm/import_export/handlers/option.py +2 -2
  47. wbfdm/import_export/parsers/cbinsights/equities.py +2 -3
  48. wbfdm/jinja2.py +2 -1
  49. wbfdm/locale/de/LC_MESSAGES/django.mo +0 -0
  50. wbfdm/locale/de/LC_MESSAGES/django.po +257 -0
  51. wbfdm/locale/en/LC_MESSAGES/django.mo +0 -0
  52. wbfdm/locale/en/LC_MESSAGES/django.po +255 -0
  53. wbfdm/locale/fr/LC_MESSAGES/django.mo +0 -0
  54. wbfdm/locale/fr/LC_MESSAGES/django.po +257 -0
  55. wbfdm/migrations/0031_exchange_apply_round_lot_size_and_more.py +23 -0
  56. wbfdm/migrations/0032_alter_instrumentprice_outstanding_shares.py +18 -0
  57. wbfdm/migrations/0033_alter_controversy_review.py +18 -0
  58. wbfdm/migrations/0034_alter_instrumentlist_instrument_list_type.py +18 -0
  59. wbfdm/models/esg/controversies.py +19 -23
  60. wbfdm/models/exchanges/exchanges.py +8 -4
  61. wbfdm/models/fields.py +2 -2
  62. wbfdm/models/fk_fields.py +3 -3
  63. wbfdm/models/instruments/instrument_lists.py +1 -0
  64. wbfdm/models/instruments/instrument_prices.py +8 -1
  65. wbfdm/models/instruments/instrument_relationships.py +3 -0
  66. wbfdm/models/instruments/instruments.py +139 -26
  67. wbfdm/models/instruments/llm/create_instrument_news_relationships.py +29 -22
  68. wbfdm/models/instruments/mixin/financials_computed.py +0 -4
  69. wbfdm/models/instruments/mixin/financials_serializer_fields.py +118 -118
  70. wbfdm/models/instruments/mixin/instruments.py +7 -4
  71. wbfdm/models/instruments/options.py +6 -0
  72. wbfdm/models/instruments/private_equities.py +3 -0
  73. wbfdm/models/instruments/querysets.py +138 -37
  74. wbfdm/models/instruments/utils.py +5 -0
  75. wbfdm/serializers/exchanges.py +1 -0
  76. wbfdm/serializers/instruments/__init__.py +1 -0
  77. wbfdm/serializers/instruments/instruments.py +9 -2
  78. wbfdm/serializers/instruments/mixins.py +3 -3
  79. wbfdm/tasks.py +13 -2
  80. wbfdm/tests/analysis/financial_analysis/test_statement_with_estimates.py +0 -1
  81. wbfdm/tests/models/test_instrument_prices.py +0 -14
  82. wbfdm/tests/models/test_instruments.py +21 -9
  83. wbfdm/tests/models/test_queryset.py +89 -0
  84. wbfdm/viewsets/configs/display/exchanges.py +1 -1
  85. wbfdm/viewsets/configs/display/financial_summary.py +2 -2
  86. wbfdm/viewsets/configs/display/instrument_prices.py +2 -70
  87. wbfdm/viewsets/configs/display/instruments.py +3 -4
  88. wbfdm/viewsets/configs/display/instruments_relationships.py +3 -1
  89. wbfdm/viewsets/configs/display/prices.py +1 -0
  90. wbfdm/viewsets/configs/display/statement_with_estimates.py +1 -2
  91. wbfdm/viewsets/configs/endpoints/classifications.py +0 -12
  92. wbfdm/viewsets/configs/endpoints/instrument_prices.py +4 -23
  93. wbfdm/viewsets/configs/titles/instrument_prices.py +2 -1
  94. wbfdm/viewsets/esg.py +2 -2
  95. wbfdm/viewsets/financial_analysis/financial_metric_analysis.py +2 -2
  96. wbfdm/viewsets/financial_analysis/financial_ratio_analysis.py +1 -1
  97. wbfdm/viewsets/financial_analysis/financial_summary.py +6 -6
  98. wbfdm/viewsets/financial_analysis/statement_with_estimates.py +7 -3
  99. wbfdm/viewsets/instruments/financials_analysis.py +9 -12
  100. wbfdm/viewsets/instruments/instrument_prices.py +9 -9
  101. wbfdm/viewsets/instruments/instruments.py +9 -7
  102. wbfdm/viewsets/instruments/utils.py +3 -3
  103. wbfdm/viewsets/market_data.py +1 -1
  104. wbfdm/viewsets/prices.py +5 -0
  105. wbfdm/viewsets/statements/statements.py +7 -3
  106. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/METADATA +2 -1
  107. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/RECORD +108 -95
  108. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/WHEEL +1 -1
  109. wbfdm/menu.py +0 -11
@@ -17,21 +17,21 @@ class InstrumentMetricDisplayConfig(DisplayViewConfig):
17
17
  def get_list_display(self) -> Optional[dp.ListDisplay]:
18
18
  return dp.ListDisplay(
19
19
  fields=[
20
+ dp.Field(key="basket_repr", label="Basket", pinned="left"),
20
21
  dp.Field(key="key", label="Key"),
21
22
  dp.Field(key="date", label="Date"),
22
23
  dp.Field(key="instrument", label="Instrument"),
23
24
  dp.Field(key="parent_metric", label="Parent Metric"),
24
25
  ],
25
26
  tree=True,
26
- tree_group_pinned="left",
27
27
  tree_group_field="basket_repr",
28
- tree_group_label="Basket",
29
28
  tree_group_level_options=[
30
29
  dp.TreeGroupLevelOption(
31
30
  filter_key="parent_metric",
32
31
  filter_depth=1,
33
32
  # lookup="id_repr",
34
33
  clear_filter=True,
34
+ filter_blacklist=["parent__isnull"],
35
35
  list_endpoint=reverse(
36
36
  "metric:instrumentmetric-list",
37
37
  args=[],
@@ -185,19 +185,19 @@ class InstrumentMetricMixin(_Base):
185
185
  serializer_class = self.get_serializer_class()
186
186
  kwargs.setdefault("context", self.get_serializer_context())
187
187
 
188
- BaseMeta = serializer_class.Meta
189
- fields = list(getattr(BaseMeta, "fields", ()))
190
- read_only_fields = list(getattr(BaseMeta, "read_only_fields", ()))
188
+ base_meta = serializer_class.Meta
189
+ fields = list(getattr(base_meta, "fields", ()))
190
+ read_only_fields = list(getattr(base_meta, "read_only_fields", ()))
191
191
  for extra_field in self._metric_serializer_fields.keys():
192
192
  fields.append(extra_field)
193
193
  read_only_fields.append(extra_field)
194
194
 
195
- Meta = type(str("Meta"), (BaseMeta,), {"fields": fields, "read_only_fields": read_only_fields})
195
+ meta = type(str("Meta"), (base_meta,), {"fields": fields, "read_only_fields": read_only_fields})
196
196
  new_class = type(
197
197
  serializer_class.__name__,
198
198
  (serializer_class,),
199
199
  {
200
- "Meta": Meta,
200
+ "Meta": meta,
201
201
  **self._metric_serializer_fields,
202
202
  "SERIALIZER_CLASS_FOR_REMOTE_ADDITIONAL_RESOURCES": serializer_class,
203
203
  },
@@ -233,7 +233,7 @@ class InstrumentMetricMixin(_Base):
233
233
  # for all the missings keys (not present in the aggregates already), we compute the aggregatation based on the aggregate function given by the MetricField class
234
234
  missing_aggregate_map = {}
235
235
  for metric_key in self.metric_keys:
236
- for field_key, field_filter in metric_key.subfields_filter_map.items():
236
+ for field_key in metric_key.subfields_filter_map.keys():
237
237
  if field_key in self._metric_serializer_fields.keys() and field_key not in aggregates:
238
238
  missing_aggregate_map[field_key] = metric_key.subfields_map[field_key]
239
239
  missing_aggregate = queryset.aggregate(
@@ -38,6 +38,7 @@ class MSCIClient:
38
38
  "grant_type": "client_credentials",
39
39
  "audience": "https://esg/data",
40
40
  },
41
+ timeout=10,
41
42
  )
42
43
  if resp.status_code == requests.codes.ok:
43
44
  with suppress(KeyError, requests.exceptions.JSONDecodeError):
@@ -58,6 +59,7 @@ class MSCIClient:
58
59
  "factor_name_list": factors,
59
60
  },
60
61
  headers={"AUTHORIZATION": f"Bearer {self.oauth_token}"},
62
+ timeout=10,
61
63
  )
62
64
  if response.ok:
63
65
  for row in response.json().get("result", {}).get("issuers", []):
@@ -66,6 +68,7 @@ class MSCIClient:
66
68
  def controversies(self, identifiers: list[str], factors: list[str]) -> Generator[dict[str, str], None, None]:
67
69
  next_url = "https://api2.msci.com/esg/data/v2.0/issuers"
68
70
  offset = 0
71
+ limit = 100
69
72
  while next_url:
70
73
  with suppress(ConnectionError):
71
74
  response = requests.post(
@@ -73,10 +76,11 @@ class MSCIClient:
73
76
  json={
74
77
  "issuer_identifier_list": identifiers,
75
78
  "factor_name_list": factors,
76
- "limit": 100,
79
+ "limit": limit,
77
80
  "offset": offset,
78
81
  },
79
82
  headers={"AUTHORIZATION": f"Bearer {self.oauth_token}"},
83
+ timeout=10,
80
84
  )
81
85
 
82
86
  if not response.ok:
@@ -87,6 +91,6 @@ class MSCIClient:
87
91
  next_url = json_res["paging"]["links"]["next"]
88
92
  except KeyError:
89
93
  next_url = None
90
- offset += 100
94
+ offset += limit
91
95
  for row in json_res.get("result", {}).get("issuers", []):
92
96
  yield row
@@ -20,6 +20,6 @@ class QARouter:
20
20
  return None
21
21
 
22
22
  def allow_migrate(self, db, app_label, model_name=None, **hints):
23
- if app_label == "qa":
23
+ if db == "qa":
24
24
  return False
25
25
  return None
@@ -57,7 +57,8 @@ class DatastreamAdjustmentsDataloader(AdjustmentsProtocol, Dataloader):
57
57
  pk.MSSQLQuery.create_table(infocode).columns(pk.Column("infocode", SqlTypes.INTEGER)).get_sql()
58
58
  )
59
59
  for batch in batched(lookup.keys(), 1000):
60
- cursor.execute(f"insert into #ds2infocode values {",".join(map(lambda x: f"({x})", batch))};")
60
+ placeholders = ",".join(map(lambda x: f"({x})", batch))
61
+ cursor.execute("insert into #ds2infocode values %s;", (placeholders,))
61
62
 
62
63
  cursor.execute(query.get_sql())
63
64
 
@@ -58,7 +58,8 @@ class DatastreamCorporateActionsDataloader(CorporateActionsProtocol, Dataloader)
58
58
  pk.MSSQLQuery.create_table(infocode).columns(pk.Column("infocode", SqlTypes.INTEGER)).get_sql()
59
59
  )
60
60
  for batch in batched(lookup.keys(), 1000):
61
- cursor.execute(f"insert into #ds2infocode values {','.join(map(lambda x: f'({x})', batch))};")
61
+ placeholders = ",".join(map(lambda x: f"({x})", batch))
62
+ cursor.execute("insert into #ds2infocode values %s;", (placeholders,))
62
63
 
63
64
  cursor.execute(query.get_sql())
64
65
  for row in dictfetchall(cursor, CorporateActionDataDict):
@@ -8,6 +8,7 @@ from jinjasql import JinjaSql # type: ignore
8
8
  from wbcore.contrib.dataloader.dataloaders import Dataloader
9
9
  from wbcore.contrib.dataloader.utils import dictfetchall
10
10
 
11
+ from wbfdm.contrib.qa.dataloaders.fx_rates import FXRateConverter
11
12
  from wbfdm.dataloaders.protocols import FinancialsProtocol
12
13
  from wbfdm.dataloaders.types import FinancialDataDict
13
14
  from wbfdm.enums import (
@@ -20,7 +21,7 @@ from wbfdm.enums import (
20
21
  )
21
22
 
22
23
 
23
- class IBESFinancialsDataloader(FinancialsProtocol, Dataloader):
24
+ class IBESFinancialsDataloader(FXRateConverter, FinancialsProtocol, Dataloader):
24
25
  def financials(
25
26
  self,
26
27
  values: list[Financial],
@@ -40,6 +41,16 @@ class IBESFinancialsDataloader(FinancialsProtocol, Dataloader):
40
41
  target_currency: str | None = None,
41
42
  ) -> Iterator[FinancialDataDict]:
42
43
  lookup = {k: v for k, v in self.entities.values_list("dl_parameters__financials__parameters", "id")}
44
+ if from_year and not from_date:
45
+ from_date = date(year=from_year, month=1, day=1)
46
+ if to_date and not to_date:
47
+ to_date = date(year=to_year + 1, month=1, day=1)
48
+
49
+ if target_currency:
50
+ if not from_date or not to_date:
51
+ raise ValueError("From date and to date needs to be properly specified to allow fx rate convertion")
52
+ self.load_fx_rates(self.entities, target_currency, from_date, to_date)
53
+
43
54
  for batch in batched(lookup.keys(), 1000):
44
55
  sql = ""
45
56
  if series_type == SeriesType.COMPLETE:
@@ -81,4 +92,11 @@ class IBESFinancialsDataloader(FinancialsProtocol, Dataloader):
81
92
  row["value_low"] = row.get("value_low", row["value"])
82
93
  row["value_amount"] = row.get("value_amount", row["value"])
83
94
  row["value_stdev"] = row.get("value_stdev", row["value"])
95
+ if target_currency:
96
+ row = self.apply_fx_rate(
97
+ row,
98
+ ["value", "value_high", "value_low", "value_amount"],
99
+ apply=True,
100
+ date_label="period_end_date",
101
+ )
84
102
  yield row
@@ -0,0 +1,86 @@
1
+ from collections import defaultdict
2
+ from datetime import date, timedelta
3
+ from typing import Iterator
4
+
5
+ import pypika as pk
6
+ from django.db import connections
7
+ from pypika import Case
8
+ from pypika import functions as fn
9
+ from pypika.enums import Order, SqlTypes
10
+ from wbcore.contrib.dataloader.dataloaders import Dataloader
11
+ from wbcore.contrib.dataloader.utils import dictfetchall
12
+
13
+ from wbfdm.dataloaders.protocols import FXRateProtocol
14
+ from wbfdm.dataloaders.types import FXRateDict
15
+
16
+
17
+ class FXRateConverter:
18
+ fx_rates: dict[str, dict[date, float]]
19
+ target_currency: str
20
+
21
+ def load_fx_rates(self, entities, target_currency: str, from_date: date, to_date: date):
22
+ fx_rates = defaultdict(dict)
23
+ self.target_currency = target_currency
24
+ if from_date:
25
+ for fx_rate in DatastreamFXRatesDataloader(entities).fx_rates(
26
+ from_date, to_date if to_date else date.today(), target_currency
27
+ ):
28
+ fx_rates[fx_rate["currency_pair"]][fx_rate["fx_date"]] = fx_rate["fx_rate"]
29
+ self.fx_rates = fx_rates
30
+
31
+ def apply_fx_rate(self, row, currency_columns: list[str], apply: bool = True, date_label: str = "valuation_date"):
32
+ if row["currency"]:
33
+ fx_rate = None
34
+ if self.target_currency == row["currency"]:
35
+ fx_rate = 1.0
36
+ else:
37
+ currency_fx_rates = self.fx_rates[f'{row["currency"]}{self.target_currency}']
38
+ if currency_fx_rates:
39
+ try:
40
+ fx_rate = currency_fx_rates[row[date_label]] or 1.0
41
+ except KeyError:
42
+ max_idx = max(currency_fx_rates.keys())
43
+ fx_rate = currency_fx_rates[max_idx]
44
+ if apply and fx_rate is not None:
45
+ for col in currency_columns:
46
+ if v := row.get(col):
47
+ row[col] = v * fx_rate
48
+ row["currency"] = self.target_currency
49
+ row["fx_rate"] = fx_rate
50
+ return row
51
+
52
+
53
+ class DatastreamFXRatesDataloader(FXRateProtocol, Dataloader):
54
+ def fx_rates(
55
+ self,
56
+ from_date: date,
57
+ to_date: date,
58
+ target_currency: str,
59
+ ) -> Iterator[FXRateDict]:
60
+ currencies = list(self.entities.values_list("currency__key", flat=True))
61
+ # Define tables
62
+ fx_rate = pk.Table("DS2FxRate")
63
+ fx_code = pk.Table("DS2FxCode")
64
+
65
+ # Base query to get data we always need unconditionally
66
+ query = (
67
+ pk.MSSQLQuery.from_(fx_rate)
68
+ # We join on _codes, which removes all instruments not in _codes - implicit where
69
+ .join(fx_code)
70
+ .on(fx_rate.ExRateIntCode == fx_code.ExRateIntCode)
71
+ .where((fx_rate.ExRateDate >= from_date) & (fx_rate.ExRateDate <= to_date + timedelta(days=1)))
72
+ .where(
73
+ (fx_code.ToCurrCode == target_currency)
74
+ & (fx_code.FromCurrCode.isin(currencies))
75
+ & (fx_code.RateTypeCode == "SPOT")
76
+ )
77
+ .orderby(fx_rate.ExRateDate, order=Order.desc)
78
+ .select(
79
+ fn.Cast(fx_rate.ExRateDate, SqlTypes.DATE).as_("fx_date"),
80
+ fn.Concat(fx_code.FromCurrCode, fx_code.ToCurrCode).as_("currency_pair"),
81
+ (Case().when(fx_code.FromCurrCode == target_currency, 1).else_(1 / fx_rate.midrate)).as_("fx_rate"),
82
+ )
83
+ )
84
+ with connections["qa"].cursor() as cursor:
85
+ cursor.execute(query.get_sql())
86
+ yield from dictfetchall(cursor, FXRateDict)
@@ -7,13 +7,14 @@ from typing import TYPE_CHECKING, Iterator
7
7
 
8
8
  import pypika as pk
9
9
  from django.db import ProgrammingError, connections
10
- from pypika import Case, Column, MSSQLQuery
10
+ from pypika import Column, MSSQLQuery
11
11
  from pypika import functions as fn
12
12
  from pypika.enums import Order, SqlTypes
13
13
  from pypika.terms import ValueWrapper
14
14
  from wbcore.contrib.dataloader.dataloaders import Dataloader
15
15
  from wbcore.contrib.dataloader.utils import dictfetchall
16
16
 
17
+ from wbfdm.contrib.qa.dataloaders.fx_rates import FXRateConverter
17
18
  from wbfdm.contrib.qa.dataloaders.utils import create_table
18
19
  from wbfdm.dataloaders.protocols import MarketDataProtocol
19
20
  from wbfdm.dataloaders.types import MarketDataDict
@@ -36,15 +37,16 @@ class DS2MarketData(Enum):
36
37
  SHARES_OUTSTANDING = "NumShrs"
37
38
 
38
39
 
39
- class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
40
- def market_data(
40
+ class DatastreamMarketDataDataloader(FXRateConverter, MarketDataProtocol, Dataloader):
41
+ def market_data( # noqa: C901
41
42
  self,
42
- values: list[MarketData] = [MarketData.CLOSE],
43
+ values: list[MarketData] | None = None,
43
44
  from_date: date | None = None,
44
45
  to_date: date | None = None,
45
46
  exact_date: date | None = None,
46
47
  frequency: Frequency = Frequency.DAILY,
47
48
  target_currency: str | None = None,
49
+ apply_fx_rate: bool = True,
48
50
  **kwargs,
49
51
  ) -> Iterator[MarketDataDict]:
50
52
  """Get market data for instruments.
@@ -59,18 +61,21 @@ class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
59
61
  Returns:
60
62
  Iterator[MarketDataDict]: An iterator of dictionaries conforming to the DailyValuationDict.
61
63
  """
62
-
63
64
  lookup = {
64
65
  f"{k[0]},{k[1]}": v for k, v in self.entities.values_list("dl_parameters__market_data__parameters", "id")
65
66
  }
66
-
67
+ if exact_date:
68
+ from_date = exact_date
69
+ to_date = exact_date
70
+ if target_currency:
71
+ self.load_fx_rates(self.entities, target_currency, from_date, to_date)
67
72
  # Define tables
68
73
  pricing = pk.Table("vw_DS2Pricing")
69
74
 
70
75
  mapping, create_mapping_table = create_table(
71
76
  "#ds2infoexchcode", Column("InfoCode", SqlTypes.INTEGER), Column("ExchIntCode", SqlTypes.INTEGER)
72
77
  )
73
-
78
+ currency_columns = [e.value for e in MarketData if e.value != MarketData.SHARES_OUTSTANDING]
74
79
  # Base query to get data we always need unconditionally
75
80
  query = (
76
81
  pk.MSSQLQuery.from_(pricing)
@@ -89,31 +94,7 @@ class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
89
94
  .orderby(pricing.MarketDate, order=Order.desc)
90
95
  )
91
96
 
92
- # if a target currency is required, we join on the fx tables and set the currency to the desired one
93
- # otherwise we just set the currency to whatever the currency is from the instrument
94
- fx_rate = None
95
- if target_currency:
96
- query = query.select(ValueWrapper(target_currency).as_("currency"))
97
- fx_code = pk.Table("DS2FxCode")
98
- fx_rate = pk.Table("DS2FxRate")
99
- query = (
100
- query
101
- # Join FX code table matching currencies and ensuring SPOT rate type
102
- .left_join(fx_code)
103
- .on(
104
- (fx_code.FromCurrCode == pricing.Currency)
105
- & (fx_code.ToCurrCode == target_currency)
106
- & (fx_code.RateTypeCode == "SPOT")
107
- )
108
- # Join FX rate table matching internal code and date
109
- .left_join(fx_rate)
110
- .on((fx_rate.ExRateIntCode == fx_code.ExRateIntCode) & (fx_rate.ExRateDate == pricing.MarketDate))
111
- # We filter out rows which do not have a proper fx rate (we exclude same currency conversions)
112
- .where((Case().when(pricing.Currency == target_currency, 1).else_(fx_rate.midrate).isnotnull()))
113
- )
114
-
115
- else:
116
- query = query.select(pricing.Currency.as_("currency"))
97
+ query = query.select(pricing.Currency.as_("currency"))
117
98
 
118
99
  # if market cap or shares outstanding are required we need to join with an additional table
119
100
  if MarketData.MARKET_CAPITALIZATION in values or MarketData.SHARES_OUTSTANDING in values:
@@ -127,9 +108,6 @@ class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
127
108
  )
128
109
 
129
110
  value = pricing_2.Close_
130
- if fx_rate:
131
- value /= Case().when(pricing_2.Currency == target_currency, 1).else_(fx_rate.midrate)
132
-
133
111
  query = query.select(value.as_("undadjusted_close"))
134
112
  query = query.select(
135
113
  MSSQLQuery.from_(num_shares)
@@ -140,14 +118,24 @@ class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
140
118
  .as_("unadjusted_outstanding_shares")
141
119
  )
142
120
 
121
+ if MarketData.MARKET_CAPITALIZATION_CONSOLIDATED in values:
122
+ mkt_val = pk.Table("DS2MktVal")
123
+ query = query.left_join(mkt_val).on(
124
+ (mkt_val.InfoCode == pricing.InfoCode) & (mkt_val.ValDate == pricing.MarketDate)
125
+ )
126
+ query = query.select((mkt_val.ConsolMktVal * 1_000_000).as_("market_capitalization_consolidated"))
127
+
143
128
  for market_data in filter(
144
- lambda x: x not in (MarketData.SHARES_OUTSTANDING, MarketData.MARKET_CAPITALIZATION), values
129
+ lambda x: x
130
+ not in (
131
+ MarketData.SHARES_OUTSTANDING,
132
+ MarketData.MARKET_CAPITALIZATION,
133
+ MarketData.MARKET_CAPITALIZATION_CONSOLIDATED,
134
+ ),
135
+ values,
145
136
  ):
146
137
  ds2_value = DS2MarketData[market_data.name].value
147
138
  value = getattr(pricing, ds2_value)
148
- if fx_rate and market_data is not MarketData.SHARES_OUTSTANDING:
149
- value /= Case().when(pricing.Currency == target_currency, 1).else_(fx_rate.midrate)
150
-
151
139
  query = query.select(value.as_(market_data.value))
152
140
 
153
141
  # Add conditional where clauses
@@ -186,7 +174,8 @@ class DatastreamMarketDataDataloader(MarketDataProtocol, Dataloader):
186
174
 
187
175
  if MarketData.SHARES_OUTSTANDING in values:
188
176
  row["outstanding_shares"] = (row["market_capitalization"] / row["close"]) if row["close"] else None
189
-
177
+ if target_currency:
178
+ row = self.apply_fx_rate(row, currency_columns, apply=apply_fx_rate)
190
179
  yield row
191
180
 
192
181
  cursor.execute(MSSQLQuery.drop_table(mapping).get_sql())
@@ -60,7 +60,7 @@ class RKDOfficersDataloader(OfficersProtocol, Dataloader):
60
60
  )
61
61
  for batch in batched(lookup.keys(), 1000):
62
62
  placeholders = ",".join(map(lambda x: f"('{x}')", batch))
63
- cursor.execute(f"insert into #rkd_codes values {placeholders};")
63
+ cursor.execute("insert into #rkd_codes values %s;", (placeholders,))
64
64
 
65
65
  cursor.execute(query.get_sql())
66
66
 
@@ -7,6 +7,7 @@ from jinjasql import JinjaSql # type: ignore
7
7
  from wbcore.contrib.dataloader.dataloaders import Dataloader
8
8
  from wbcore.contrib.dataloader.utils import dictfetchall
9
9
 
10
+ from wbfdm.contrib.qa.dataloaders.fx_rates import FXRateConverter
10
11
  from wbfdm.dataloaders.protocols import StatementsProtocol
11
12
  from wbfdm.dataloaders.types import StatementDataDict
12
13
  from wbfdm.enums import DataType, Financial, PeriodType, StatementType
@@ -224,7 +225,7 @@ standardized_sql = """
224
225
  """
225
226
 
226
227
 
227
- class RKDStatementsDataloader(StatementsProtocol, Dataloader):
228
+ class RKDStatementsDataloader(FXRateConverter, StatementsProtocol, Dataloader):
228
229
  def statements(
229
230
  self,
230
231
  statement_type: StatementType | None = None,
@@ -239,6 +240,15 @@ class RKDStatementsDataloader(StatementsProtocol, Dataloader):
239
240
  ) -> Iterator[StatementDataDict]:
240
241
  lookup = {k: v for k, v in self.entities.values_list("dl_parameters__statements__parameters", "id")}
241
242
  sql = reported_sql if data_type is DataType.REPORTED else standardized_sql
243
+ if not financials:
244
+ financials = []
245
+ external_codes = [RKDFinancial[fin.name].value for fin in financials if fin.name in RKDFinancial.__members__]
246
+ if from_year and not from_date:
247
+ from_date = date(year=from_year, month=1, day=1)
248
+ if to_date and not to_date:
249
+ to_date = date(year=to_year + 1, month=1, day=1)
250
+ if target_currency:
251
+ self.load_fx_rates(self.entities, target_currency, from_date, to_date)
242
252
  query, bind_params = JinjaSql(param_style="format").prepare_query(
243
253
  sql,
244
254
  {
@@ -249,7 +259,7 @@ class RKDStatementsDataloader(StatementsProtocol, Dataloader):
249
259
  "from_date": from_date,
250
260
  "to_date": to_date,
251
261
  "period_type": period_type.value,
252
- "external_codes": [RKDFinancial[fin.name].value for fin in financials or []],
262
+ "external_codes": external_codes,
253
263
  },
254
264
  )
255
265
  with connections["qa"].cursor() as cursor:
@@ -264,5 +274,10 @@ class RKDStatementsDataloader(StatementsProtocol, Dataloader):
264
274
  row["year"] = int(row["year"] or row["period_end_date"].year)
265
275
  row["instrument_id"] = lookup[row["external_identifier"]]
266
276
  if financials:
267
- row["financial"] = Financial[RKDFinancial(row["external_code"]).name].value
277
+ try:
278
+ row["financial"] = Financial[RKDFinancial(row["external_code"]).name].value
279
+ except (ValueError, KeyError):
280
+ continue
281
+ if target_currency:
282
+ row = self.apply_fx_rate(row, ["value"], apply=True, date_label="period_end_date")
268
283
  yield row
@@ -52,7 +52,7 @@ where
52
52
  or (fin.ExpireDate is null and ExpireDate is null)
53
53
  )
54
54
  )
55
-
55
+ and fin.isParent = 'false'
56
56
  and fin.EstPermID in (
57
57
  {% for instrument in instruments %}
58
58
  {{ instrument }}{% if not loop.last %},{% endif %}
@@ -32,7 +32,8 @@ class QAExchangeSync(Sync[Exchange]):
32
32
 
33
33
  def update_or_create_item(self, external_id: int) -> Exchange:
34
34
  defaults = self.get_item(external_id)
35
- defaults["country_id"] = mapping(Geography.countries, "code_2").get(defaults["country_id"])
35
+ if country_id := defaults.get("country_id"):
36
+ defaults["country_id"] = mapping(Geography.countries, "code_2").get(country_id)
36
37
  exchange, _ = Exchange.objects.update_or_create(
37
38
  source=self.SOURCE,
38
39
  source_id=external_id,
@@ -1,12 +1,17 @@
1
+ import logging
1
2
  from contextlib import suppress
3
+ from datetime import date, timedelta
2
4
  from typing import Callable
3
5
 
4
6
  import pytz
5
7
  from django.conf import settings
6
- from django.db import connections
8
+ from django.core.exceptions import ObjectDoesNotExist
9
+ from django.db import IntegrityError, connections
7
10
  from django.db.models import Max, QuerySet
8
11
  from django.template.loader import get_template
9
12
  from jinjasql import JinjaSql # type: ignore
13
+ from mptt.exceptions import InvalidMove
14
+ from psycopg.errors import UniqueViolation
10
15
  from tqdm import tqdm
11
16
  from wbcore.contrib.currency.models import Currency
12
17
  from wbcore.contrib.dataloader.utils import dictfetchall, dictfetchone
@@ -15,6 +20,8 @@ from wbcore.utils.cache import mapping
15
20
  from wbfdm.models.exchanges.exchanges import Exchange
16
21
  from wbfdm.models.instruments.instruments import Instrument, InstrumentType
17
22
 
23
+ logger = logging.getLogger("pms")
24
+
18
25
  BATCH_SIZE: int = 10000
19
26
  instrument_type_map = {
20
27
  "ADR": "american_depository_receipt",
@@ -135,6 +142,39 @@ def get_instrument_from_data(data, parent_source: str | None = None) -> Instrume
135
142
  return instrument
136
143
 
137
144
 
145
+ def _delist_existing_duplicates(instrument: Instrument) -> None:
146
+ """Handle duplicate instruments by delisting existing entries"""
147
+ unique_identifiers = ["refinitiv_identifier_code", "refinitiv_mnemonic_code", "isin", "sedol", "valoren", "cusip"]
148
+
149
+ for identifier_field in unique_identifiers:
150
+ if identifier := getattr(instrument, identifier_field):
151
+ if instrument.delisted_date: # if delisted, we unset the identifier that can lead to constraint error
152
+ setattr(instrument, identifier_field, None)
153
+ else:
154
+ with suppress(Instrument.DoesNotExist):
155
+ duplicate = Instrument.objects.get(
156
+ is_security=True, delisted_date__isnull=True, **{identifier_field: identifier}
157
+ )
158
+ duplicate.delisted_date = date.today() - timedelta(days=1)
159
+ duplicate.save()
160
+
161
+
162
+ def _save_single_instrument(instrument: Instrument) -> None:
163
+ """Attempt to save an instrument with duplicate handling"""
164
+ try:
165
+ instrument.save()
166
+ except (UniqueViolation, IntegrityError) as e:
167
+ if instrument.is_security:
168
+ _delist_existing_duplicates(instrument)
169
+ try:
170
+ instrument.save()
171
+ logger.info(f"{instrument} successfully saved after automatic delisting")
172
+ except (UniqueViolation, IntegrityError) as e:
173
+ logger.error(f"Persistent integrity error: {e}")
174
+ else:
175
+ logger.error(f"Non-security instrument error: {e}")
176
+
177
+
138
178
  def _bulk_create_instruments_chunk(instruments: list[Instrument], update_unique_identifiers: bool = False):
139
179
  update_fields = [
140
180
  "name",
@@ -168,9 +208,17 @@ def _bulk_create_instruments_chunk(instruments: list[Instrument], update_unique_
168
208
  ]
169
209
  )
170
210
  bulk_update_kwargs = {"ignore_conflicts": True}
171
- Instrument.objects.bulk_create(
172
- instruments, update_fields=update_fields, unique_fields=["source", "source_id"], **bulk_update_kwargs
173
- )
211
+ try:
212
+ Instrument.objects.bulk_create(
213
+ instruments, update_fields=update_fields, unique_fields=["source", "source_id"], **bulk_update_kwargs
214
+ )
215
+ except IntegrityError:
216
+ # we caught an integrity error on the bulk save, so we try to save one by one
217
+ logger.error(
218
+ "we detected an integrity error while bulk saving instruments. We save them one by one and delist the already existing instrument from the db if we can. "
219
+ )
220
+ for instrument in instruments:
221
+ _save_single_instrument(instrument)
174
222
 
175
223
 
176
224
  def update_instruments(sql_name: str, parent_source: str | None = None, context=None, debug: bool = False, **kwargs):
@@ -198,22 +246,29 @@ def update_instruments(sql_name: str, parent_source: str | None = None, context=
198
246
 
199
247
  def update_or_create_item(
200
248
  external_id: int, get_item: Callable, source: str, parent_source: str | None = None
201
- ) -> Instrument:
249
+ ) -> Instrument | None:
202
250
  defaults = convert_data(get_item(external_id), parent_source=parent_source)
203
251
 
204
252
  dl_parameters = defaults.pop("dl_parameters", {})
205
253
  defaults.pop("source", None)
206
254
  defaults.pop("source_id", None)
255
+ try:
256
+ instrument, _ = Instrument.objects.update_or_create(
257
+ source=source,
258
+ source_id=external_id,
259
+ defaults=defaults,
260
+ )
261
+ instrument.dl_parameters.update(dl_parameters)
262
+ _save_single_instrument(instrument)
263
+ return instrument
207
264
 
208
- instrument, _ = Instrument.objects.update_or_create(
209
- source=source,
210
- source_id=external_id,
211
- defaults=defaults,
212
- )
213
- instrument.dl_parameters.update(dl_parameters)
214
- instrument.save()
215
-
216
- return instrument
265
+ except (
266
+ InvalidMove,
267
+ ObjectDoesNotExist,
268
+ ): # we might encounter a object does not exist error in case the inserted parent does not exist yet in our db which might happen if it was just deleted because invalid
269
+ logger.warning(
270
+ f"We encountered a MPTT ill-formed node for source ID {external_id}. Rebuilding the tree might be necessary."
271
+ )
217
272
 
218
273
 
219
274
  def get_item(external_id: int, template_name: str) -> dict:
@@ -238,13 +293,17 @@ def trigger_partial_update(
238
293
  else:
239
294
  with connections["qa"].cursor() as cursor:
240
295
  cursor.execute(
241
- f"SELECT MAX(last_user_update) FROM sys.dm_db_index_usage_stats WHERE OBJECT_NAME(object_id) = '{table_change_name}'"
296
+ "SELECT MAX(last_user_update) FROM sys.dm_db_index_usage_stats WHERE OBJECT_NAME(object_id) = %s",
297
+ (table_change_name,),
242
298
  )
243
299
  max_last_updated_qa = (
244
300
  pytz.timezone(settings.TIME_ZONE).localize(result[0]) if (result := cursor.fetchone()) else None
245
301
  )
246
302
  if max_last_updated_qa and max_last_updated_qa > max_last_updated:
247
303
  for _, security_id in cursor.execute(
248
- f"SELECT UpdateFlag_, {id_field} FROM {table_change_name}"
304
+ f"SELECT UpdateFlag_, {id_field} FROM {table_change_name}" # noqa: S608
249
305
  ).fetchall():
250
- update_or_create_item(security_id)
306
+ try:
307
+ update_or_create_item(security_id)
308
+ except Exception as e:
309
+ logger.error(f"Error updating instrument {security_id}: {e}")