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.
- wbfdm/admin/exchanges.py +1 -1
- wbfdm/admin/instruments.py +3 -2
- wbfdm/analysis/financial_analysis/change_point_detection.py +88 -0
- wbfdm/analysis/financial_analysis/statement_with_estimates.py +5 -6
- wbfdm/analysis/financial_analysis/utils.py +6 -0
- wbfdm/contrib/dsws/client.py +3 -3
- wbfdm/contrib/dsws/dataloaders/market_data.py +31 -3
- wbfdm/contrib/internal/dataloaders/market_data.py +43 -9
- wbfdm/contrib/metric/backends/base.py +2 -2
- wbfdm/contrib/metric/backends/statistics.py +47 -13
- wbfdm/contrib/metric/dispatch.py +3 -0
- wbfdm/contrib/metric/exceptions.py +1 -1
- wbfdm/contrib/metric/filters.py +19 -0
- wbfdm/contrib/metric/models.py +6 -0
- wbfdm/contrib/metric/orchestrators.py +4 -4
- wbfdm/contrib/metric/signals.py +7 -0
- wbfdm/contrib/metric/tasks.py +2 -3
- wbfdm/contrib/metric/viewsets/configs/display.py +2 -2
- wbfdm/contrib/metric/viewsets/mixins.py +6 -6
- wbfdm/contrib/msci/client.py +6 -2
- wbfdm/contrib/qa/database_routers.py +1 -1
- wbfdm/contrib/qa/dataloaders/adjustments.py +2 -1
- wbfdm/contrib/qa/dataloaders/corporate_actions.py +2 -1
- wbfdm/contrib/qa/dataloaders/financials.py +19 -1
- wbfdm/contrib/qa/dataloaders/fx_rates.py +86 -0
- wbfdm/contrib/qa/dataloaders/market_data.py +29 -40
- wbfdm/contrib/qa/dataloaders/officers.py +1 -1
- wbfdm/contrib/qa/dataloaders/statements.py +18 -3
- wbfdm/contrib/qa/jinja2/qa/sql/ibes/financials.sql +1 -1
- wbfdm/contrib/qa/sync/exchanges.py +2 -1
- wbfdm/contrib/qa/sync/utils.py +76 -17
- wbfdm/dataloaders/protocols.py +12 -1
- wbfdm/dataloaders/proxies.py +15 -1
- wbfdm/dataloaders/types.py +7 -1
- wbfdm/enums.py +2 -0
- wbfdm/factories/instruments.py +4 -2
- wbfdm/figures/financials/financial_analysis_charts.py +2 -8
- wbfdm/filters/classifications.py +2 -2
- wbfdm/filters/financials.py +9 -18
- wbfdm/filters/financials_analysis.py +36 -16
- wbfdm/filters/instrument_prices.py +8 -5
- wbfdm/filters/instruments.py +21 -7
- wbfdm/import_export/backends/cbinsights/utils/client.py +8 -8
- wbfdm/import_export/backends/refinitiv/utils/controller.py +1 -1
- wbfdm/import_export/handlers/instrument.py +160 -104
- wbfdm/import_export/handlers/option.py +2 -2
- wbfdm/import_export/parsers/cbinsights/equities.py +2 -3
- wbfdm/jinja2.py +2 -1
- wbfdm/locale/de/LC_MESSAGES/django.mo +0 -0
- wbfdm/locale/de/LC_MESSAGES/django.po +257 -0
- wbfdm/locale/en/LC_MESSAGES/django.mo +0 -0
- wbfdm/locale/en/LC_MESSAGES/django.po +255 -0
- wbfdm/locale/fr/LC_MESSAGES/django.mo +0 -0
- wbfdm/locale/fr/LC_MESSAGES/django.po +257 -0
- wbfdm/migrations/0031_exchange_apply_round_lot_size_and_more.py +23 -0
- wbfdm/migrations/0032_alter_instrumentprice_outstanding_shares.py +18 -0
- wbfdm/migrations/0033_alter_controversy_review.py +18 -0
- wbfdm/migrations/0034_alter_instrumentlist_instrument_list_type.py +18 -0
- wbfdm/models/esg/controversies.py +19 -23
- wbfdm/models/exchanges/exchanges.py +8 -4
- wbfdm/models/fields.py +2 -2
- wbfdm/models/fk_fields.py +3 -3
- wbfdm/models/instruments/instrument_lists.py +1 -0
- wbfdm/models/instruments/instrument_prices.py +8 -1
- wbfdm/models/instruments/instrument_relationships.py +3 -0
- wbfdm/models/instruments/instruments.py +139 -26
- wbfdm/models/instruments/llm/create_instrument_news_relationships.py +29 -22
- wbfdm/models/instruments/mixin/financials_computed.py +0 -4
- wbfdm/models/instruments/mixin/financials_serializer_fields.py +118 -118
- wbfdm/models/instruments/mixin/instruments.py +7 -4
- wbfdm/models/instruments/options.py +6 -0
- wbfdm/models/instruments/private_equities.py +3 -0
- wbfdm/models/instruments/querysets.py +138 -37
- wbfdm/models/instruments/utils.py +5 -0
- wbfdm/serializers/exchanges.py +1 -0
- wbfdm/serializers/instruments/__init__.py +1 -0
- wbfdm/serializers/instruments/instruments.py +9 -2
- wbfdm/serializers/instruments/mixins.py +3 -3
- wbfdm/tasks.py +13 -2
- wbfdm/tests/analysis/financial_analysis/test_statement_with_estimates.py +0 -1
- wbfdm/tests/models/test_instrument_prices.py +0 -14
- wbfdm/tests/models/test_instruments.py +21 -9
- wbfdm/tests/models/test_queryset.py +89 -0
- wbfdm/viewsets/configs/display/exchanges.py +1 -1
- wbfdm/viewsets/configs/display/financial_summary.py +2 -2
- wbfdm/viewsets/configs/display/instrument_prices.py +2 -70
- wbfdm/viewsets/configs/display/instruments.py +3 -4
- wbfdm/viewsets/configs/display/instruments_relationships.py +3 -1
- wbfdm/viewsets/configs/display/prices.py +1 -0
- wbfdm/viewsets/configs/display/statement_with_estimates.py +1 -2
- wbfdm/viewsets/configs/endpoints/classifications.py +0 -12
- wbfdm/viewsets/configs/endpoints/instrument_prices.py +4 -23
- wbfdm/viewsets/configs/titles/instrument_prices.py +2 -1
- wbfdm/viewsets/esg.py +2 -2
- wbfdm/viewsets/financial_analysis/financial_metric_analysis.py +2 -2
- wbfdm/viewsets/financial_analysis/financial_ratio_analysis.py +1 -1
- wbfdm/viewsets/financial_analysis/financial_summary.py +6 -6
- wbfdm/viewsets/financial_analysis/statement_with_estimates.py +7 -3
- wbfdm/viewsets/instruments/financials_analysis.py +9 -12
- wbfdm/viewsets/instruments/instrument_prices.py +9 -9
- wbfdm/viewsets/instruments/instruments.py +9 -7
- wbfdm/viewsets/instruments/utils.py +3 -3
- wbfdm/viewsets/market_data.py +1 -1
- wbfdm/viewsets/prices.py +5 -0
- wbfdm/viewsets/statements/statements.py +7 -3
- {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/METADATA +2 -1
- {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/RECORD +108 -95
- {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/WHEEL +1 -1
- 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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
598
|
-
if self.
|
|
599
|
-
|
|
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
|
-
|
|
603
|
-
for
|
|
604
|
-
if v := getattr(self,
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
704
|
-
repr = self.name_repr
|
|
705
|
-
|
|
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.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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=
|
|
60
|
+
HumanMessage(content="Title: {title}, Description: {description}"),
|
|
61
61
|
],
|
|
62
62
|
output_model=CompaniesModel,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|