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
|
@@ -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
|
-
|
|
189
|
-
fields = list(getattr(
|
|
190
|
-
read_only_fields = list(getattr(
|
|
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
|
-
|
|
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":
|
|
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
|
|
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(
|
wbfdm/contrib/msci/client.py
CHANGED
|
@@ -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":
|
|
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 +=
|
|
94
|
+
offset += limit
|
|
91
95
|
for row in json_res.get("result", {}).get("issuers", []):
|
|
92
96
|
yield row
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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":
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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,
|
wbfdm/contrib/qa/sync/utils.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
+
try:
|
|
307
|
+
update_or_create_item(security_id)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error(f"Error updating instrument {security_id}: {e}")
|