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
wbfdm/models/fields.py CHANGED
@@ -23,7 +23,7 @@ class CompositeKey(models.AutoField):
23
23
  def __init__(self, columns: list[str], db_column_ref: str | None = None, *args, **kwargs):
24
24
  self.columns = columns
25
25
  self.db_column_ref = db_column_ref
26
- super().__init__(primary_key=True, *args, **kwargs)
26
+ super().__init__(*args, primary_key=True, **kwargs)
27
27
 
28
28
  def contribute_to_class(self, cls, name, private_only=False):
29
29
  self.set_attributes_from_name(name)
@@ -108,7 +108,7 @@ class Exact(models.Lookup):
108
108
 
109
109
  lookups = [
110
110
  lookup_class(field.get_col(self.lhs.alias), self.rhs[column])
111
- for lookup_class, field, column in zip(lookup_classes, fields, self.lhs.field.columns)
111
+ for lookup_class, field, column in zip(lookup_classes, fields, self.lhs.field.columns, strict=False)
112
112
  ]
113
113
 
114
114
  value_constraint = WhereNode()
wbfdm/models/fk_fields.py CHANGED
@@ -93,7 +93,7 @@ class CompositeForeignKey(ForeignObject):
93
93
  kwargs["on_delete"] = self.override_on_delete(kwargs["on_delete"])
94
94
 
95
95
  kwargs["to_fields"], kwargs["from_fields"] = zip(
96
- *((k, v.value) for k, v in self._raw_fields.items() if v.is_local_field)
96
+ *((k, v.value) for k, v in self._raw_fields.items() if v.is_local_field), strict=False
97
97
  )
98
98
  super().__init__(to, **kwargs)
99
99
 
@@ -220,7 +220,7 @@ class CompositeForeignKey(ForeignObject):
220
220
 
221
221
  def _check_nullifequal_fields_exists(self):
222
222
  res = []
223
- for field_name, value in self.null_if_equal:
223
+ for field_name, _ in self.null_if_equal:
224
224
  try:
225
225
  self.model._meta.get_field(field_name)
226
226
  except FieldDoesNotExist:
@@ -269,7 +269,7 @@ class CompositeForeignKey(ForeignObject):
269
269
 
270
270
  return OrderedDict(
271
271
  (k, (v if isinstance(v, CompositePart) else LocalFieldValue(v)))
272
- for k, v in (to_fields.items() if isinstance(to_fields, dict) else zip(to_fields, to_fields))
272
+ for k, v in (to_fields.items() if isinstance(to_fields, dict) else zip(to_fields, to_fields, strict=False))
273
273
  )
274
274
 
275
275
  def db_type(self, connection):
@@ -68,6 +68,7 @@ class InstrumentList(WBModel):
68
68
  class InstrumentListType(models.TextChoices):
69
69
  WATCH = "WATCH", "Watch List"
70
70
  EXCLUSION = "EXCLUSION", "Exclusion List"
71
+ INCLUSION = "INCLUSION", "Inclusion List"
71
72
 
72
73
  name = models.CharField(max_length=255)
73
74
  identifier = models.CharField(max_length=255, unique=True, blank=True)
@@ -195,9 +195,11 @@ class InstrumentPrice(
195
195
  verbose_name="Value (Gross)",
196
196
  ) # TODO: I think we need to remove this field that is not really used here.
197
197
 
198
- outstanding_shares = DynamicDecimalField(
198
+ outstanding_shares = models.DecimalField(
199
199
  decimal_places=4,
200
200
  max_digits=16,
201
+ blank=True,
202
+ null=True,
201
203
  verbose_name="Outstanding Shares",
202
204
  help_text="The amount of outstanding share for this instrument",
203
205
  )
@@ -340,6 +342,11 @@ class InstrumentPrice(
340
342
 
341
343
  if self.market_capitalization_consolidated is None:
342
344
  self.market_capitalization_consolidated = self.market_capitalization
345
+
346
+ # if the instrument is of type cash, we enforce the net value to 1
347
+ if self.instrument.is_cash or self.instrument.is_cash_equivalent:
348
+ self.net_value = Decimal("1")
349
+ self.gross_value = Decimal("1")
343
350
  super().save(*args, **kwargs)
344
351
 
345
352
  def __str__(self):
@@ -162,6 +162,9 @@ class RelatedInstrumentThroughModel(models.Model):
162
162
  class Meta:
163
163
  unique_together = ("instrument", "related_instrument", "is_primary", "related_type")
164
164
 
165
+ def __str__(self) -> str:
166
+ return f"{self.instrument} - {self.related_instrument} ({self.related_type})"
167
+
165
168
  def save(self, *args, **kwargs):
166
169
  qs = RelatedInstrumentThroughModel.objects.filter(
167
170
  instrument=self.instrument, related_type=self.related_type, is_primary=True
@@ -1,8 +1,10 @@
1
+ import logging
1
2
  import re
2
3
  from contextlib import suppress
3
4
  from datetime import date, timedelta
4
5
  from typing import Any, Generator, Iterator, Self, TypeVar
5
6
 
7
+ import pandas as pd
6
8
  from celery import shared_task
7
9
  from colorfield.fields import ColorField
8
10
  from django.contrib import admin
@@ -44,13 +46,17 @@ from wbfdm.preferences import get_default_classification_group
44
46
  from wbfdm.signals import (
45
47
  add_instrument_to_investable_universe,
46
48
  instrument_price_imported,
49
+ investable_universe_updated,
47
50
  )
48
51
 
52
+ from ...analysis.financial_analysis.change_point_detection import outlier_detection, statistical_change_point_detection
49
53
  from ...dataloaders.proxies import InstrumentDataloaderProxy
50
54
  from .instrument_relationships import RelatedInstrumentThroughModel
51
55
  from .mixin.instruments import InstrumentPMSMixin
52
56
  from .querysets import InstrumentQuerySet
53
- from .utils import re_bloomberg, re_isin, re_mnemonic, re_ric
57
+ from .utils import clean_ric, re_bloomberg, re_isin, re_mnemonic, re_ric
58
+
59
+ logger = logging.getLogger("pms")
54
60
 
55
61
 
56
62
  class InstrumentManager(TreeManager):
@@ -133,6 +139,12 @@ class InstrumentManager(TreeManager):
133
139
  def filter_active_at_date(self, val_date: date):
134
140
  return self.get_queryset().filter_active_at_date(val_date)
135
141
 
142
+ def get_instrument_prices_from_market_data(self, **kwargs):
143
+ return self.get_queryset().get_instrument_prices_from_market_data(**kwargs)
144
+
145
+ def get_returns_df(self, **kwargs) -> tuple[dict[date, dict[int, float]], pd.DataFrame]:
146
+ return self.get_queryset().get_returns_df(**kwargs)
147
+
136
148
 
137
149
  class SecurityInstrumentManager(InstrumentManager):
138
150
  def get_queryset(self) -> InstrumentQuerySet:
@@ -193,38 +205,38 @@ class InstrumentType(models.Model):
193
205
 
194
206
  @classmethod
195
207
  @property
196
- def PRODUCT(cls):
208
+ def PRODUCT(cls): # noqa
197
209
  return InstrumentType.objects.get_or_create(
198
210
  key="product", defaults={"name": "Product", "short_name": "Product"}
199
211
  )[0]
200
212
 
201
213
  @classmethod
202
214
  @property
203
- def EQUITY(cls):
215
+ def EQUITY(cls): # noqa
204
216
  return InstrumentType.objects.get_or_create(key="equity", defaults={"name": "equity", "short_name": "equity"})[
205
217
  0
206
218
  ]
207
219
 
208
220
  @classmethod
209
221
  @property
210
- def INDEX(cls):
222
+ def INDEX(cls): # noqa
211
223
  return InstrumentType.objects.get_or_create(key="index", defaults={"name": "Index", "short_name": "Index"})[0]
212
224
 
213
225
  @classmethod
214
226
  @property
215
- def CASH(cls):
227
+ def CASH(cls): # noqa
216
228
  return InstrumentType.objects.get_or_create(key="cash", defaults={"name": "Cash", "short_name": "Cash"})[0]
217
229
 
218
230
  @classmethod
219
231
  @property
220
- def CASHEQUIVALENT(cls):
232
+ def CASHEQUIVALENT(cls): # noqa
221
233
  return InstrumentType.objects.get_or_create(
222
234
  key="cash_equivalent", defaults={"name": "Cash Equivalents", "short_name": "Cash Equivalents"}
223
235
  )[0]
224
236
 
225
237
  @classmethod
226
238
  @property
227
- def PRODUCT_GROUP(cls):
239
+ def PRODUCT_GROUP(cls): # noqa
228
240
  return InstrumentType.objects.get_or_create(
229
241
  key="product_group", defaults={"name": "Product Group", "short_name": "Product Group"}
230
242
  )[0]
@@ -306,6 +318,11 @@ class Instrument(ComplexToStringMixin, TagModelMixin, ImportMixin, InstrumentPMS
306
318
  exchange = models.ForeignKey(
307
319
  to="wbfdm.Exchange", null=True, blank=True, on_delete=models.PROTECT, related_name="instruments"
308
320
  )
321
+ round_lot_size = models.IntegerField(
322
+ default=1,
323
+ verbose_name="Round Lot Size",
324
+ help_text="A round lot (or board lot) is the normal unit of trading of a security.",
325
+ )
309
326
 
310
327
  source_id = models.CharField(max_length=64, null=True, blank=True)
311
328
  source = models.CharField(max_length=64, null=True, blank=True)
@@ -594,17 +611,24 @@ class Instrument(ComplexToStringMixin, TagModelMixin, ImportMixin, InstrumentPMS
594
611
 
595
612
  @property
596
613
  def identifier_repr(self) -> str:
597
- identifier_repr = ""
598
- if self.instrument_type and self.instrument_type.key == "product":
599
- # Then we prioritize ISIN over ticker
600
- identifiers = ["isin", "ticker", "refinitiv_identifier_code", "refinitiv_mnemonic_code"]
614
+ identifiers = []
615
+ if self.is_security:
616
+ identifier_labels = ["ticker", "refinitiv_identifier_code", "refinitiv_mnemonic_code"]
601
617
  else:
602
- identifiers = ["ticker", "isin", "refinitiv_identifier_code", "refinitiv_mnemonic_code"]
603
- for identifier in identifiers:
604
- if v := getattr(self, identifier, None):
605
- identifier_repr = v
618
+ identifier_labels = ["refinitiv_mnemonic_code"]
619
+ for label in identifier_labels:
620
+ if v := getattr(self, label, None):
621
+ identifiers.append(v)
606
622
  break
607
- return identifier_repr.replace(":", "-")
623
+ if self.isin and self.is_security:
624
+ identifiers.append(self.isin)
625
+
626
+ return " - ".join(identifiers).replace(":", "-")
627
+
628
+ @property
629
+ def bloomberg_ticker(self):
630
+ if self.exchange and (bbg_composite := self.exchange.bbg_composite):
631
+ return self.ticker + " " + bbg_composite
608
632
 
609
633
  @property
610
634
  def valuations(self):
@@ -652,11 +676,11 @@ class Instrument(ComplexToStringMixin, TagModelMixin, ImportMixin, InstrumentPMS
652
676
  raise ValidationError("An instrument in the investable universe cannot have children")
653
677
  return self
654
678
 
655
- def pre_save(self):
679
+ def pre_save(self): # noqa: C901
656
680
  if self.instrument_type:
657
681
  self.is_security = self.instrument_type.is_security
658
- if self.delisted_date:
659
- self.is_security = False
682
+ # if self.delisted_date:
683
+ # self.is_security = False
660
684
  if not self.name_repr:
661
685
  self.name_repr = self.name
662
686
  if not self.founded_year and self.inception_date:
@@ -693,6 +717,12 @@ class Instrument(ComplexToStringMixin, TagModelMixin, ImportMixin, InstrumentPMS
693
717
  self.instrument_type = child.instrument_type
694
718
  if not self.currency:
695
719
  self.currency = child.currency
720
+ if (
721
+ self.refinitiv_identifier_code
722
+ and self.exchange
723
+ and (exchange_ric := self.exchange.refinitiv_identifier_code)
724
+ ):
725
+ self.refinitiv_identifier_code = clean_ric(self.refinitiv_identifier_code, exchange_ric)
696
726
 
697
727
  def save(self, *args, **kwargs):
698
728
  self.pre_save()
@@ -700,16 +730,22 @@ class Instrument(ComplexToStringMixin, TagModelMixin, ImportMixin, InstrumentPMS
700
730
  self.is_primary = not self.parent or (self.exchange is not None and self.parent.exchange == self.exchange)
701
731
  super().save(*args, **kwargs)
702
732
 
703
- def compute_str(self):
704
- repr = self.name_repr # we follow bloomberg instrument representation format
705
- if self.instrument_type:
733
+ def get_compute_str(self):
734
+ repr = self.name_repr or self.name or ""
735
+ repr = repr.title() # we follow bloomberg instrument representation format
736
+ if self.instrument_type and self.is_security:
706
737
  repr += f" {self.instrument_type.short_name}"
707
- if self.identifier_repr:
708
- repr += f" - {self.identifier_repr}"
709
- if self.exchange:
710
- repr += f" ({str(self.exchange)})"
738
+ if self.is_security or not self.level == 0:
739
+ if self.identifier_repr:
740
+ repr += f" - {self.identifier_repr}"
741
+ # if the object has an exchange and is not a security nor a company (a quote then), we append the exchange representation
742
+ if self.exchange and self.parent is not None and not self.is_security:
743
+ repr += f" ({str(self.exchange)})"
711
744
  return repr
712
745
 
746
+ def compute_str(self):
747
+ return self.get_compute_str()
748
+
713
749
  def is_active_at_date(self, today: date) -> bool:
714
750
  return (
715
751
  self.inception_date is not None
@@ -929,6 +965,9 @@ def pre_save_instrument(sender, instance, raw, **kwargs):
929
965
  and pre_instance.isin not in instance.old_isins
930
966
  ):
931
967
  instance.old_isins = [*instance.old_isins, pre_instance.isin]
968
+ for children in instance.children.all():
969
+ children.isin = instance.isin
970
+ children.save()
932
971
  if pre_instance.name_repr != instance.name_repr:
933
972
  # if a family member get is name representation updated, we update it for the whole family
934
973
  pre_instance.get_family().update(name_repr=instance.name_repr)
@@ -970,6 +1009,10 @@ class Cash(Instrument):
970
1009
 
971
1010
  def save(self, *args, **kwargs):
972
1011
  self.is_cash = True
1012
+ self.dl_parameters["market_data"] = {
1013
+ "path": "wbfdm.contrib.internal.dataloaders.market_data.MarketDataDataloader"
1014
+ }
1015
+
973
1016
  super().save(*args, **kwargs)
974
1017
 
975
1018
  class Meta:
@@ -999,3 +1042,73 @@ class Equity(Instrument):
999
1042
  @receiver(create_news_relationships, sender="wbnews.News")
1000
1043
  def get_news_relationships_for_instruments_task(sender: type, instance: "News", **kwargs) -> shared_task:
1001
1044
  return run_company_extraction_llm.s(instance.title, instance.description, instance.summary)
1045
+
1046
+
1047
+ @shared_task(queue="pms")
1048
+ def detect_and_correct_financial_timeseries(
1049
+ max_days_interval: int | None = None,
1050
+ check_date: date | None = None,
1051
+ with_pelt: bool = False,
1052
+ detect_only: bool = False,
1053
+ full_reimport: bool = False,
1054
+ debug: bool = False,
1055
+ ):
1056
+ """Detects and corrects anomalies in financial time series data for instruments.
1057
+
1058
+ Analyzes price data using statistical methods to identify outliers and change points,
1059
+ then triggers price reimport for affected date ranges when corrections are needed.
1060
+
1061
+ Args:
1062
+ max_days_interval: Maximum lookback window in days for analysis (None = all history)
1063
+ check_date: Reference date for analysis (defaults to current date)
1064
+ with_pelt: Enable Pelt's change point detection alongside basic z-score outlier detection
1065
+ detect_only: Run detection without performing data correction/reimport
1066
+ full_reimport: Reimport entire price history when corruption detected (requires max_days_interval=None)
1067
+ debug: Show progress bar during instrument processing
1068
+
1069
+ """
1070
+ if not check_date:
1071
+ check_date = date.today()
1072
+ gen = (
1073
+ Instrument.investable_universe.filter(is_managed=False)
1074
+ .filter_active_at_date(check_date)
1075
+ .exclude(source="dsws")
1076
+ )
1077
+ if debug:
1078
+ gen = tqdm(gen, total=gen.count())
1079
+ for instrument in gen:
1080
+ prices = instrument.valuations.all()
1081
+ if max_days_interval:
1082
+ prices = prices.filter(date__gte=check_date - timedelta(days=max_days_interval))
1083
+ # construct the price timeseries
1084
+ prices_series = (
1085
+ pd.DataFrame(
1086
+ prices.filter_only_valid_prices().values_list("date", "net_value"), columns=["date", "net_value"]
1087
+ )
1088
+ .set_index("date")["net_value"]
1089
+ .astype(float)
1090
+ .sort_index()
1091
+ )
1092
+ if not prices_series.empty:
1093
+ outliers = outlier_detection(prices_series).index.tolist()
1094
+ # if pelt enable, add the outliers found by the PELT model
1095
+ if with_pelt:
1096
+ outliers.extend(statistical_change_point_detection(prices_series).index.tolist())
1097
+ if outliers:
1098
+ logger.info(f"Abnormal change point detected for {instrument} at {outliers}.")
1099
+ if not detect_only:
1100
+ # for a full reimport, we delete the whole existing price series and reimport since inception
1101
+ if full_reimport and not max_days_interval:
1102
+ start_import_date = instrument.inception_date
1103
+ end_import_date = check_date
1104
+ instrument.prices.filter(assets__isnull=True).delete()
1105
+ else:
1106
+ start_import_date = min(outliers) - timedelta(days=7)
1107
+ end_import_date = max(outliers) + timedelta(days=7)
1108
+ logger.info(f"Reimporting price from {start_import_date} to {end_import_date}...")
1109
+ instrument.import_prices(start=start_import_date, end=end_import_date)
1110
+
1111
+
1112
+ @receiver(investable_universe_updated, sender="wbfdm.Instrument")
1113
+ def investable_universe_change_point_detection(*args, end_date: date | None = None, **kwargs):
1114
+ detect_and_correct_financial_timeseries.delay(check_date=end_date, max_days_interval=365)
@@ -4,8 +4,8 @@ from typing import Any
4
4
  from celery import shared_task
5
5
  from django.contrib.contenttypes.models import ContentType
6
6
  from langchain_core.messages import HumanMessage, SystemMessage
7
- from pydantic import BaseModel, Field
8
- from wbcore.contrib.ai.exceptions import APIStatusErrors
7
+ from pydantic import BaseModel, Field, ValidationError
8
+ from wbcore.contrib.ai.exceptions import APIStatusErrors, BadRequestErrors
9
9
  from wbcore.contrib.ai.llm.utils import run_llm
10
10
 
11
11
  logger = logging.getLogger("llm")
@@ -57,29 +57,36 @@ def run_company_extraction_llm(title: str, description: str, *args) -> list[dict
57
57
  SystemMessage(
58
58
  content="You will be parsed a news article, please provide the name of the publicly listed companies mentioned in the article, along with their ISIN, ticker, RIC, sentiment, and analysis."
59
59
  ),
60
- HumanMessage(content=f"Title: {title}, Description: {description}"),
60
+ HumanMessage(content="Title: {title}, Description: {description}"),
61
61
  ],
62
62
  output_model=CompaniesModel,
63
- )
64
- instrument_ct = ContentType.objects.get_for_model(Instrument)
65
- for company in res.get("companies", []):
66
- instrument = InstrumentLookup(Instrument).lookup(
67
- only_security=True,
68
- name=company.name,
69
- isin=company.isin,
70
- ticker=company.ticker,
71
- refinitiv_identifier_code=company.refinitiv_identifier_code,
72
- )
73
- if instrument is not None:
74
- relationships.append(
75
- {
76
- "content_type_id": instrument_ct.id,
77
- "object_id": instrument.get_root().id,
78
- "sentiment": company.sentiment,
79
- "analysis": company.analysis,
80
- "content_object_repr": str(instrument),
81
- }
63
+ query={"title": title, "description": description},
64
+ )[0]
65
+ if isinstance(res, CompaniesModel):
66
+ instrument_ct = ContentType.objects.get_for_model(Instrument)
67
+ for company in res.companies:
68
+ instrument = InstrumentLookup(Instrument).lookup(
69
+ only_security=True,
70
+ name=company.name,
71
+ isin=company.isin,
72
+ ticker=company.ticker,
73
+ refinitiv_identifier_code=company.refinitiv_identifier_code,
82
74
  )
75
+ if instrument is not None:
76
+ relationships.append(
77
+ {
78
+ "content_type_id": instrument_ct.id,
79
+ "object_id": instrument.get_root().id,
80
+ "sentiment": company.sentiment,
81
+ "analysis": company.analysis,
82
+ "content_object_repr": str(instrument),
83
+ }
84
+ )
85
+ except (
86
+ ValidationError,
87
+ *BadRequestErrors,
88
+ ): # we silent bad request error because there is nothing we can do about it
89
+ pass
83
90
  except tuple(APIStatusErrors) as e: # for APIStatusError, we let celery retry it
84
91
  raise e
85
92
  except Exception as e: # otherwise we log the error and silently fail
@@ -697,10 +697,6 @@ import pandas as pd
697
697
  #
698
698
  #
699
699
  class InstrumentPriceComputedMixin:
700
- def _compute_outstanding_shares(self):
701
- if self.outstanding_shares is None and (previous_price := self.previous_price):
702
- return previous_price.outstanding_shares
703
-
704
700
  def _compute_outstanding_shares_consolidated(self):
705
701
  if self.outstanding_shares_consolidated is None and self.outstanding_shares is not None:
706
702
  return self.outstanding_shares