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
@@ -42,11 +42,14 @@ class InstrumentPMSMixin:
42
42
  def get_price(self, val_date: date, price_date_timedelta: int = 3) -> Decimal:
43
43
  if self.is_cash:
44
44
  return Decimal(1)
45
- return self._build_dto(val_date, price_date_timedelta=price_date_timedelta).close
45
+ return Decimal(self._build_dto(val_date, price_date_timedelta=price_date_timedelta).close)
46
46
 
47
47
  def _build_dto(self, val_date: date, price_date_timedelta: int = 3) -> PriceDTO: # for backward compatibility
48
48
  try:
49
- price = self.valuations.get(date=val_date)
49
+ try:
50
+ price = self.valuations.get(date=val_date)
51
+ except InstrumentPrice.DoesNotExist:
52
+ price = self.prices.get(date=val_date)
50
53
  close = float(price.net_value)
51
54
  return PriceDTO(
52
55
  pk=price.id,
@@ -60,7 +63,7 @@ class InstrumentPMSMixin:
60
63
  market_capitalization=price.market_capitalization,
61
64
  outstanding_shares=float(price.outstanding_shares) if price.outstanding_shares else None,
62
65
  )
63
- except InstrumentPrice.DoesNotExist:
66
+ except InstrumentPrice.DoesNotExist as e:
64
67
  prices = sorted(
65
68
  self.get_prices(from_date=(val_date - BDay(price_date_timedelta)).date(), to_date=val_date),
66
69
  key=lambda x: x["valuation_date"],
@@ -84,7 +87,7 @@ class InstrumentPMSMixin:
84
87
  market_capitalization=p.get("market_capitalization", None),
85
88
  outstanding_shares=p.get("outstanding_shares", None),
86
89
  )
87
- raise ValueError("Not price was found")
90
+ raise ValueError("Not price was found") from e
88
91
 
89
92
  # Instrument Prices Utility Functions
90
93
  @classmethod
@@ -143,6 +143,9 @@ class OptionAggregate(BaseOptionAbstractModel):
143
143
  ),
144
144
  ]
145
145
 
146
+ def __str__(self) -> str:
147
+ return f"{self.instrument} - {self.date} - {self.type}"
148
+
146
149
 
147
150
  class Option(BaseOptionAbstractModel):
148
151
  import_export_handler_class = OptionImportHandler
@@ -224,3 +227,6 @@ class Option(BaseOptionAbstractModel):
224
227
  fields=["type"],
225
228
  ),
226
229
  ]
230
+
231
+ def __str__(self):
232
+ return f"{self.contract_identifier} - {self.date} - {self.type}"
@@ -58,3 +58,6 @@ class Deal(ImportMixin, models.Model):
58
58
  null=True,
59
59
  help_text="List of URLs used to source the valuation for the Media Mentions source type.",
60
60
  )
61
+
62
+ def __str__(self) -> str:
63
+ return f"{self.equity} - {self.date}"
@@ -1,11 +1,15 @@
1
- import math
1
+ import logging
2
2
  from contextlib import suppress
3
- from datetime import date
3
+ from datetime import date, timedelta
4
+ from decimal import Decimal
4
5
 
5
6
  import numpy as np
6
7
  import pandas as pd
8
+ from django.core.exceptions import MultipleObjectsReturned, ValidationError
9
+ from django.core.validators import DecimalValidator
7
10
  from django.db.models import (
8
11
  AutoField,
12
+ Case,
9
13
  Exists,
10
14
  ExpressionWrapper,
11
15
  F,
@@ -13,12 +17,17 @@ from django.db.models import (
13
17
  Q,
14
18
  QuerySet,
15
19
  Subquery,
20
+ Value,
21
+ When,
16
22
  )
17
23
  from django.db.models.functions import Coalesce
18
- from wbcore.contrib.currency.models import CurrencyFXRates
24
+ from skfolio.preprocessing import prices_to_returns
25
+ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
19
26
 
20
27
  from wbfdm.enums import MarketData
21
28
 
29
+ logger = logging.getLogger("pms")
30
+
22
31
 
23
32
  class InstrumentQuerySet(QuerySet):
24
33
  def filter_active_at_date(self, val_date: date):
@@ -88,43 +97,49 @@ class InstrumentQuerySet(QuerySet):
88
97
  from wbfdm.models import InstrumentPrice
89
98
 
90
99
  def _dict_to_object(instrument, row):
91
- if (
92
- (price_date := row.get("date"))
93
- and (close := row.get("close", None))
94
- and int(math.log10(close)) + 1 < 10
95
- ):
96
- try:
100
+ close = row.get("close", None)
101
+ price_date = row.get("date")
102
+ if price_date and close is not None:
103
+ close = round(Decimal(close), 6)
104
+ # we validate that close can be inserting into our table<
105
+ with suppress(ValidationError):
106
+ validator = DecimalValidator(16, 6)
107
+ validator(close)
97
108
  try:
98
- p = InstrumentPrice.objects.get(instrument=instrument, date=price_date, calculated=False)
99
- except InstrumentPrice.DoesNotExist:
100
- p = InstrumentPrice.objects.get(instrument=instrument, date=price_date, calculated=True)
101
- p.net_value = close
102
- p.gross_value = close
103
- p.calculated = row["calculated"]
104
- p.volume = row.get("volume", p.volume)
105
- p.market_capitalization = row.get("market_capitalization", p.market_capitalization)
106
- p.market_capitalization_consolidated = p.market_capitalization
107
- p.set_dynamic_field(False)
108
- p.id = None
109
- return p
110
- except InstrumentPrice.DoesNotExist:
111
- with suppress(CurrencyFXRates.DoesNotExist):
112
- p = InstrumentPrice(
113
- currency_fx_rate_to_usd=CurrencyFXRates.objects.get(
114
- # we need to get the currency rate because we bulk create the object, and thus save is not called
115
- date=price_date,
116
- currency=instrument.currency,
117
- ),
118
- instrument=instrument,
119
- date=price_date,
120
- calculated=row["calculated"],
121
- net_value=close,
122
- gross_value=close,
123
- volume=row.get("volume", None),
124
- market_capitalization=row.get("market_capitalization", None),
125
- )
109
+ try:
110
+ InstrumentPrice.objects.get(instrument=instrument, date=price_date)
111
+ except MultipleObjectsReturned:
112
+ InstrumentPrice.objects.get(
113
+ instrument=instrument, date=price_date, calculated=False
114
+ ).delete()
115
+ p = InstrumentPrice.objects.get(instrument=instrument, date=price_date)
116
+ p.net_value = close
117
+ p.gross_value = close
118
+ p.calculated = row["calculated"]
119
+ p.volume = row.get("volume", p.volume)
120
+ p.market_capitalization = row.get("market_capitalization", p.market_capitalization)
121
+ p.market_capitalization_consolidated = p.market_capitalization
126
122
  p.set_dynamic_field(False)
123
+ p.id = None
127
124
  return p
125
+ except InstrumentPrice.DoesNotExist:
126
+ with suppress(CurrencyFXRates.DoesNotExist):
127
+ p = InstrumentPrice(
128
+ currency_fx_rate_to_usd=CurrencyFXRates.objects.get(
129
+ # we need to get the currency rate because we bulk create the object, and thus save is not called
130
+ date=price_date,
131
+ currency=instrument.currency,
132
+ ),
133
+ instrument=instrument,
134
+ date=price_date,
135
+ calculated=row["calculated"],
136
+ net_value=close,
137
+ gross_value=close,
138
+ volume=row.get("volume", None),
139
+ market_capitalization=row.get("market_capitalization", None),
140
+ )
141
+ p.set_dynamic_field(False)
142
+ return p
128
143
 
129
144
  df = pd.DataFrame(
130
145
  self.dl.market_data(
@@ -145,15 +160,101 @@ class InstrumentQuerySet(QuerySet):
145
160
 
146
161
  for instrument_id, dff in df.groupby("instrument_id", group_keys=False, as_index=False):
147
162
  dff = dff.drop(columns=["instrument_id"]).set_index("date").sort_index()
163
+ if dff.index.duplicated().any():
164
+ dff = dff.groupby(level=0).first()
165
+ logger.warning(
166
+ f"We detected a duplicated index for instrument id {instrument_id}. Please correct the dl parameter which likely introduced this issue."
167
+ )
168
+
148
169
  dff = dff.reindex(pd.date_range(dff.index.min(), dff.index.max(), freq="B"))
170
+
149
171
  dff[["close", "market_capitalization"]] = dff[["close", "market_capitalization"]].astype(float).ffill()
150
172
  dff.volume = dff.volume.astype(float).fillna(0)
151
173
  dff.calculated = dff.calculated.astype(bool).fillna(
152
174
  True
153
175
  ) # we do not ffill calculated but set the to True to mark them as "estimated"/"not real"
176
+
154
177
  dff = dff.reset_index(names="date").dropna(subset=["close"])
155
178
  dff = dff.replace([np.inf, -np.inf, np.nan], None)
156
179
  instrument = self.get(id=instrument_id)
180
+
157
181
  yield from filter(
158
182
  lambda x: x, map(lambda row: _dict_to_object(instrument, row), dff.to_dict("records"))
159
183
  )
184
+
185
+ def get_returns_df(
186
+ self, from_date: date, to_date: date, to_currency: Currency | None = None, use_dl: bool = False
187
+ ) -> tuple[dict[date, dict[int, float]], pd.DataFrame]:
188
+ """
189
+ Utility methods to get instrument returns for a given date range
190
+
191
+ Args:
192
+ from_date: date range lower bound
193
+ to_date: date range upper bound
194
+ to_currency: currency to use for returns
195
+ use_dl: whether to get data straight from the dataloader or use the internal table
196
+
197
+ Returns:
198
+ Return a tuple of the raw prices and the returns dataframe
199
+ """
200
+ padded_from_date = from_date - timedelta(days=15)
201
+ padded_to_date = to_date + timedelta(days=3)
202
+ logger.info(
203
+ f"Loading returns from {from_date:%Y-%m-%d} (padded to {padded_from_date:%Y-%m-%d}) to {to_date:%Y-%m-%d} (padded to {padded_to_date:%Y-%m-%d}) for {self.count()} instruments"
204
+ )
205
+
206
+ if use_dl:
207
+ kwargs = dict(
208
+ from_date=padded_from_date, to_date=padded_to_date, values=[MarketData.CLOSE], apply_fx_rate=False
209
+ )
210
+ if to_currency:
211
+ kwargs["target_currency"] = to_currency.key
212
+ df = pd.DataFrame(self.dl.market_data(**kwargs))
213
+ if df.empty:
214
+ df = pd.DataFrame(columns=["instrument_id", "fx_rate", "close", "valuation_date"])
215
+ else:
216
+ df = df[["instrument_id", "fx_rate", "close", "valuation_date"]]
217
+ else:
218
+ from wbfdm.models import InstrumentPrice
219
+
220
+ if to_currency:
221
+ fx_rate = Coalesce(
222
+ CurrencyFXRates.get_fx_rates_subquery_for_two_currencies(
223
+ "date", "instrument__currency", to_currency
224
+ ),
225
+ Decimal("1"),
226
+ )
227
+ else:
228
+ fx_rate = Value(Decimal("1"))
229
+ # annotate fx rate only if the price is not calculated, in that case we assume the instrument is not tradable and we set a forex of None (to be fast forward filled)
230
+ prices = InstrumentPrice.objects.filter(
231
+ instrument__in=self, date__gte=padded_from_date, date__lte=padded_to_date
232
+ ).annotate(fx_rate=Case(When(calculated=False, then=fx_rate), default=None))
233
+ df = pd.DataFrame(
234
+ prices.filter_only_valid_prices().values_list("instrument", "fx_rate", "net_value", "date"),
235
+ columns=["instrument_id", "fx_rate", "close", "valuation_date"],
236
+ )
237
+ df = (
238
+ df.pivot_table(index="valuation_date", columns="instrument_id", values=["fx_rate", "close"], dropna=False)
239
+ .astype(float)
240
+ .sort_index()
241
+ )
242
+ if not df.empty:
243
+ ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
244
+ df = df.reindex(ts)
245
+ df = df.ffill()
246
+ df.index = pd.to_datetime(df.index)
247
+ df = df[
248
+ (df.index <= pd.Timestamp(to_date)) & (df.index >= pd.Timestamp(from_date))
249
+ ] # ensure the returned df corresponds to requested date range
250
+ prices_df = df["close"]
251
+ if "fx_rate" in df.columns:
252
+ fx_rate_df = df["fx_rate"].fillna(1.0)
253
+ else:
254
+ fx_rate_df = pd.DataFrame(np.ones(prices_df.shape), index=prices_df.index, columns=prices_df.columns)
255
+ returns = prices_to_returns(fx_rate_df * prices_df, drop_inceptions_nan=False, fill_nan=True)
256
+
257
+ return {
258
+ ts.date(): row for ts, row in prices_df.replace([np.nan], None).to_dict("index").items()
259
+ }, returns.replace([np.inf, -np.inf, np.nan], 0)
260
+ return {}, pd.DataFrame()
@@ -40,3 +40,8 @@ def re_isin(input: str):
40
40
 
41
41
  def re_mnemonic(input: str):
42
42
  return set(re.findall(START_DELIMITER + r"([A-Z]+:[A-Z]+)" + END_DELIMITER, input))
43
+
44
+
45
+ def clean_ric(ric: str, exchange_code: str):
46
+ # Replace the matched exchange code with the new exchange
47
+ return re.sub(r"\.\w+$", f".{exchange_code}", ric)
@@ -37,4 +37,5 @@ class ExchangeModelSerializer(wb_serializers.ModelSerializer):
37
37
  "_city",
38
38
  "city",
39
39
  "comments",
40
+ "apply_round_lot_size",
40
41
  )
@@ -4,6 +4,7 @@ from .instruments import (
4
4
  InstrumentRepresentationSerializer,
5
5
  InstrumentTypeRepresentationSerializer,
6
6
  InvestableUniverseRepresentationSerializer,
7
+ CompanyRepresentationSerializer,
7
8
  ClassifiableInstrumentRepresentationSerializer,
8
9
  SecurityRepresentationSerializer,
9
10
  InvestableInstrumentRepresentationSerializer,
@@ -51,6 +51,13 @@ class ClassifiableInstrumentRepresentationSerializer(InstrumentRepresentationSer
51
51
  return filter_params
52
52
 
53
53
 
54
+ class CompanyRepresentationSerializer(InstrumentRepresentationSerializer):
55
+ def get_filter_params(self, request):
56
+ filter_params = super().get_filter_params(request)
57
+ filter_params["level"] = 0
58
+ return filter_params
59
+
60
+
54
61
  class SecurityRepresentationSerializer(InstrumentRepresentationSerializer):
55
62
  def get_filter_params(self, request):
56
63
  filter_params = super().get_filter_params(request)
@@ -89,14 +96,14 @@ class ManagedInstrumentRepresentationSerializer(InstrumentRepresentationSerializ
89
96
  class EquityRepresentationSerializer(InstrumentRepresentationSerializer):
90
97
  def get_filter_params(self, request):
91
98
  filter_params = super().get_filter_params(request)
92
- filter_params["investment_type__key"] = "equity"
99
+ filter_params["instrument_type__key"] = "equity"
93
100
  return filter_params
94
101
 
95
102
 
96
103
  class ProductRepresentationSerializer(InstrumentRepresentationSerializer):
97
104
  def get_filter_params(self, request):
98
105
  filter_params = super().get_filter_params(request)
99
- filter_params["investment_type__key"] = "product"
106
+ filter_params["instrument_type__key"] = "product"
100
107
  return filter_params
101
108
 
102
109
 
@@ -29,9 +29,9 @@ class InstrumentAdditionalResourcesMixin:
29
29
  args=[instance.id],
30
30
  request=request,
31
31
  )
32
- additional_resources[
33
- "price_and_volume"
34
- ] = f'{reverse("wbfdm:market_data-list", args=[instance.id], request=request)}?chart_type=close&indicators=sma_50,sma_100&volume=true'
32
+ additional_resources["price_and_volume"] = (
33
+ f'{reverse("wbfdm:market_data-list", args=[instance.id], request=request)}?chart_type=close&indicators=sma_50,sma_100&volume=true'
34
+ )
35
35
 
36
36
  additional_resources["classifications_list"] = reverse(
37
37
  "wbfdm:instrument-classification-list",
wbfdm/tasks.py CHANGED
@@ -1,10 +1,12 @@
1
+ from contextlib import suppress
1
2
  from datetime import date, timedelta
2
3
 
3
4
  from celery import shared_task
4
5
  from django.db import transaction
5
- from django.db.models import Q
6
+ from django.db.models import ProtectedError, Q
6
7
  from pandas.tseries.offsets import BDay
7
8
  from tqdm import tqdm
9
+ from wbcore.utils.cache import mapping
8
10
 
9
11
  from wbfdm.models import Instrument, InstrumentPrice
10
12
  from wbfdm.sync.runner import ( # noqa: F401
@@ -14,6 +16,7 @@ from wbfdm.sync.runner import ( # noqa: F401
14
16
  synchronize_instruments,
15
17
  )
16
18
 
19
+ from .contrib.metric.signals import instrument_metric_updated
17
20
  from .signals import investable_universe_updated
18
21
 
19
22
 
@@ -51,7 +54,6 @@ def update_of_investable_universe_data(
51
54
  Instrument.investable_universe.update(
52
55
  is_investable_universe=True
53
56
  ) # ensure all the investable universe is marked as such
54
-
55
57
  instruments = Instrument.active_objects.filter(is_investable_universe=True, delisted_date__isnull=True).exclude(
56
58
  Q(is_managed=True)
57
59
  | Q(dl_parameters__market_data__path="wbfdm.contrib.internal.dataloaders.market_data.MarketDataDataloader")
@@ -71,6 +73,7 @@ def update_instrument_metrics_as_task():
71
73
  instruments = Instrument.active_objects.filter(is_investable_universe=True)
72
74
  for instrument in tqdm(instruments, total=instruments.count()):
73
75
  instrument.update_last_valuation_date()
76
+ instrument_metric_updated.send(sender=Instrument, basket=None, date=None, key=None)
74
77
 
75
78
 
76
79
  @shared_task(queue="portfolio")
@@ -85,6 +88,14 @@ def synchronize_exchanges_as_task():
85
88
 
86
89
  @shared_task(queue="portfolio")
87
90
  def full_synchronization_as_task():
91
+ # we get all instrument without name or where we would expect a parent and consider them for clean up.
92
+ qs = Instrument.objects.filter(prices__isnull=True).filter(
93
+ (Q(name="") & Q(name_repr="")) | (Q(source__in=["qa-ds2-security", "qa-ds2-quote"]) & Q(parent__isnull=True))
94
+ )
95
+ for instrument in qs:
96
+ with suppress(ProtectedError):
97
+ instrument.delete()
98
+ mapping.cache_clear() # we need to clear the mapping cache because we might have deleted parent instruments
88
99
  initialize_exchanges()
89
100
  initialize_instruments()
90
101
  with transaction.atomic():
@@ -384,7 +384,6 @@ class TestStatementWithEstimates:
384
384
  "shares_outstanding",
385
385
  "market_capitalization",
386
386
  "close",
387
- "market_capitalization",
388
387
  "net_cash",
389
388
  "period_end_date",
390
389
  "estimate",
@@ -1,10 +1,8 @@
1
1
  from datetime import date
2
- from decimal import Decimal
3
2
 
4
3
  import pandas as pd
5
4
  import pytest
6
5
  from faker import Faker
7
- from pandas.tseries.offsets import BDay
8
6
  from wbcore.models import DynamicDecimalField, DynamicFloatField
9
7
 
10
8
  from wbfdm.models import Instrument, InstrumentPrice, RelatedInstrumentThroughModel
@@ -168,18 +166,6 @@ class TestInstrumentPriceModel:
168
166
  assert isinstance(instrument_price._meta.get_field("gross_value"), DynamicDecimalField)
169
167
  assert instrument_price.gross_value == instrument_price.net_value
170
168
 
171
- @pytest.mark.parametrize("instrument_price__outstanding_shares", [Decimal(10)])
172
- def test_compute_outstanding_shares(self, instrument_price, instrument_price_factory):
173
- next_price = instrument_price_factory.create(
174
- instrument=instrument_price.instrument,
175
- date=instrument_price.date + BDay(1),
176
- outstanding_shares=None,
177
- calculated=instrument_price.calculated,
178
- )
179
- assert hasattr(instrument_price, "_compute_outstanding_shares")
180
- assert isinstance(instrument_price._meta.get_field("outstanding_shares"), DynamicDecimalField)
181
- assert next_price.outstanding_shares == instrument_price.outstanding_shares
182
-
183
169
  @pytest.mark.parametrize("instrument_price__volume_50d", [None])
184
170
  def test_compute_volume_50d(self, instrument_price, instrument_price_factory):
185
171
  assert hasattr(instrument_price, "_compute_volume_50d")
@@ -38,23 +38,25 @@ class TestInstrumentModel:
38
38
  assert res[0]["market_capitalization"] == price.market_capitalization
39
39
  assert res[0]["outstanding_shares"] == float(price.outstanding_shares)
40
40
 
41
- def test_get_price(self, instrument_factory, instrument_price_factory):
41
+ def test_get_price(self, weekday, instrument_factory, instrument_price_factory):
42
42
  instrument = instrument_factory.create()
43
43
  other_instrument = instrument_factory.create()
44
- price = instrument_price_factory.create(instrument=instrument)
45
- price.refresh_from_db()
44
+ price_calculated = instrument_price_factory.create(date=weekday, instrument=instrument, calculated=True)
45
+
46
46
  instrument_price_factory.create(instrument=other_instrument) # Noise
47
- assert instrument.get_price(price.date) == float(price.net_value)
48
- assert instrument.get_price((price.date + BDay(1)).date()) == float(price.net_value)
49
- assert instrument.get_price((price.date + BDay(2)).date()) == float(price.net_value)
50
- assert instrument.get_price((price.date + BDay(3)).date()) == float(price.net_value)
47
+ assert instrument.get_price(weekday) == float(price_calculated.net_value)
48
+ price_real = instrument_price_factory.create(date=weekday, instrument=instrument, calculated=False)
49
+ assert instrument.get_price(weekday) == float(price_real.net_value) # we prioritize real price
50
+ assert instrument.get_price((weekday + BDay(1)).date()) == float(price_real.net_value)
51
+ assert instrument.get_price((weekday + BDay(2)).date()) == float(price_real.net_value)
52
+ assert instrument.get_price((weekday + BDay(3)).date()) == float(price_real.net_value)
51
53
  with pytest.raises(ValueError):
52
- instrument.get_price((price.date + BDay(4)).date()) # for return the latest valid price 3 days earlier.
54
+ instrument.get_price((weekday + BDay(4)).date()) # for return the latest valid price 3 days earlier.
53
55
 
54
56
  # if the instrument is considered cash, we always return a value of 1
55
57
  instrument.is_cash = True
56
58
  instrument.save()
57
- assert instrument.get_price(price.date) == Decimal(1)
59
+ assert instrument.get_price(weekday) == Decimal(1)
58
60
 
59
61
  def test_extract_daily_performance_df(self):
60
62
  tidx = pd.date_range("2016-07-01", periods=4, freq="B")
@@ -205,3 +207,13 @@ class TestInstrumentModel:
205
207
  parent.save()
206
208
  instrument.refresh_from_db()
207
209
  assert instrument.name_repr == "test2"
210
+
211
+ def test_clean_ric(self, instrument_factory, exchange_factory):
212
+ exchange = exchange_factory.create(refinitiv_identifier_code=None)
213
+ instrument = instrument_factory.create(refinitiv_identifier_code="AAPL.AA", exchange=exchange)
214
+ assert instrument.refinitiv_identifier_code == "AAPL.AA"
215
+
216
+ exchange.refinitiv_identifier_code = "BB"
217
+ exchange.save()
218
+ instrument.save()
219
+ assert instrument.refinitiv_identifier_code == "AAPL.BB"
@@ -0,0 +1,89 @@
1
+ from datetime import date
2
+ from decimal import Decimal
3
+
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ from wbfdm.models import Instrument
8
+
9
+
10
+ @pytest.mark.django_db
11
+ class TestInstrumentQueryset:
12
+ def test_get_returns(self, instrument_factory, instrument_price_factory):
13
+ v1 = date(2024, 12, 31)
14
+ v2 = date(2025, 1, 1)
15
+ v3 = date(2025, 1, 2)
16
+ v4 = date(2025, 1, 3)
17
+
18
+ i1 = instrument_factory.create()
19
+ i2 = instrument_factory.create()
20
+
21
+ i11 = instrument_price_factory.create(date=v1, instrument=i1)
22
+ i12 = instrument_price_factory.create(date=v2, instrument=i1)
23
+ i14 = instrument_price_factory.create(date=v4, instrument=i1)
24
+ i21 = instrument_price_factory.create(date=v1, instrument=i2)
25
+ i11.refresh_from_db()
26
+ i12.refresh_from_db()
27
+ i14.refresh_from_db()
28
+ prices, returns = Instrument.objects.filter(id__in=[i1.id, i2.id]).get_returns_df(from_date=v1, to_date=v4)
29
+
30
+ expected_returns = pd.DataFrame(
31
+ [[i12.net_value / i11.net_value - 1, 0.0], [0.0, 0.0], [i14.net_value / i12.net_value - 1, 0.0]],
32
+ index=[v2, v3, v4],
33
+ columns=[i1.id, i2.id],
34
+ dtype="float64",
35
+ )
36
+ expected_returns.index = pd.to_datetime(expected_returns.index)
37
+ pd.testing.assert_frame_equal(returns, expected_returns, check_names=False, check_freq=False, atol=1e-6)
38
+ assert prices[v1][i1.id] == float(i11.net_value)
39
+ assert prices[v2][i1.id] == float(i12.net_value)
40
+ assert prices[v3][i1.id] == float(i12.net_value)
41
+ assert prices[v4][i1.id] == float(i14.net_value)
42
+ # test that the returned price are ffill
43
+ assert prices[v1][i2.id] == float(i21.net_value)
44
+ assert prices[v2][i2.id] == float(i21.net_value)
45
+ assert prices[v3][i2.id] == float(i21.net_value)
46
+ assert prices[v4][i2.id] == float(i21.net_value)
47
+
48
+ def test_get_returns_fix_forex_on_holiday(
49
+ self, instrument, instrument_price_factory, currency_fx_rates_factory, currency_factory
50
+ ):
51
+ v1 = date(2024, 12, 31)
52
+ v2 = date(2025, 1, 1)
53
+ v3 = date(2025, 1, 2)
54
+
55
+ target_currency = currency_factory.create()
56
+ fx_target1 = currency_fx_rates_factory.create(currency=target_currency, date=v1)
57
+ fx_target2 = currency_fx_rates_factory.create(currency=target_currency, date=v2) # noqa
58
+ fx_target3 = currency_fx_rates_factory.create(currency=target_currency, date=v3)
59
+
60
+ fx1 = currency_fx_rates_factory.create(currency=instrument.currency, date=v1)
61
+ fx2 = currency_fx_rates_factory.create(currency=instrument.currency, date=v2) # noqa
62
+ fx3 = currency_fx_rates_factory.create(currency=instrument.currency, date=v3)
63
+
64
+ i1 = instrument_price_factory.create(net_value=Decimal("100"), date=v1, instrument=instrument)
65
+ i2 = instrument_price_factory.create(net_value=Decimal("100"), date=v2, instrument=instrument, calculated=True)
66
+ i3 = instrument_price_factory.create(net_value=Decimal("200"), date=v3, instrument=instrument)
67
+
68
+ prices, returns = Instrument.objects.filter(id__in=[instrument.id]).get_returns_df(
69
+ from_date=v1, to_date=v3, to_currency=target_currency
70
+ )
71
+ returns.index = pd.to_datetime(returns.index)
72
+ assert prices[v1][instrument.id] == float(i1.net_value)
73
+ assert prices[v2][instrument.id] == float(i2.net_value)
74
+ assert prices[v3][instrument.id] == float(i3.net_value)
75
+
76
+ assert returns.loc[pd.Timestamp(v2), instrument.id] == pytest.approx(
77
+ float(
78
+ (i2.net_value * fx_target1.value / fx1.value) / (i1.net_value * fx_target1.value / fx1.value)
79
+ - Decimal("1")
80
+ ),
81
+ abs=10e-8,
82
+ ) # as v2 as a calculated price, the forex won't apply to it
83
+ assert returns.loc[pd.Timestamp(v3), instrument.id] == pytest.approx(
84
+ float(
85
+ (i3.net_value * fx_target3.value / fx3.value) / (i2.net_value * fx_target1.value / fx1.value)
86
+ - Decimal("1")
87
+ ),
88
+ abs=10e-8,
89
+ )
@@ -35,7 +35,7 @@ class ExchangeDisplayConfig(DisplayViewConfig):
35
35
  [
36
36
  ["name", "mic_code", "operating_mic_code", "refinitiv_identifier_code"],
37
37
  [".", "bbg_exchange_codes", "bbg_composite_primary", "bbg_composite"],
38
- [repeat_field(2, "country"), "city", "website"],
38
+ ["country", "city", "website", "apply_round_lot_size"],
39
39
  [repeat_field(2, "opening_time"), repeat_field(2, "closing_time")],
40
40
  [repeat_field(4, "comments")],
41
41
  ]
@@ -80,7 +80,7 @@ class FinancialSummaryDisplayViewConfig(DisplayViewConfig):
80
80
  label=col,
81
81
  width=80,
82
82
  formatting_rules=generate_formatting_rules(col),
83
- auto_size=False,
83
+ suppress_auto_size=False,
84
84
  resizable=False,
85
85
  movable=False,
86
86
  menu=False,
@@ -93,7 +93,7 @@ class FinancialSummaryDisplayViewConfig(DisplayViewConfig):
93
93
  key="label",
94
94
  label=" ",
95
95
  width=120,
96
- auto_size=False,
96
+ suppress_auto_size=False,
97
97
  resizable=False,
98
98
  movable=False,
99
99
  menu=False,