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/admin/exchanges.py
CHANGED
|
@@ -43,7 +43,7 @@ class ExchangeModelAdmin(admin.ModelAdmin):
|
|
|
43
43
|
("operating_mic_code", "operating_mic_name"),
|
|
44
44
|
("bbg_exchange_codes", "bbg_composite_primary", "bbg_composite"),
|
|
45
45
|
("refinitiv_identifier_code", "refinitiv_mnemonic"),
|
|
46
|
-
("country", "website"),
|
|
46
|
+
("country", "website", "apply_round_lot_size"),
|
|
47
47
|
("comments",),
|
|
48
48
|
)
|
|
49
49
|
},
|
wbfdm/admin/instruments.py
CHANGED
|
@@ -50,8 +50,8 @@ class InstrumentModelAdmin(admin.ModelAdmin):
|
|
|
50
50
|
{
|
|
51
51
|
"fields": (
|
|
52
52
|
("name", "name_repr", "computed_str"),
|
|
53
|
-
("parent", "instrument_type"),
|
|
54
|
-
("is_cash", "is_security", "is_managed", "
|
|
53
|
+
("parent", "instrument_type", "is_primary"),
|
|
54
|
+
("is_cash", "is_security", "is_managed", "is_cash_equivalent", "is_investable_universe"),
|
|
55
55
|
("inception_date", "delisted_date"),
|
|
56
56
|
("primary_url", "additional_urls"),
|
|
57
57
|
(
|
|
@@ -69,6 +69,7 @@ class InstrumentModelAdmin(admin.ModelAdmin):
|
|
|
69
69
|
("currency", "country", "headquarter_city", "headquarter_address"),
|
|
70
70
|
(
|
|
71
71
|
"exchange",
|
|
72
|
+
"round_lot_size",
|
|
72
73
|
"import_source",
|
|
73
74
|
"base_color",
|
|
74
75
|
),
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import ruptures as rpt
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def outlier_detection(series, z_threshold=3, window=11) -> pd.Series:
|
|
6
|
+
"""
|
|
7
|
+
Enhanced detection with volatility-adjusted thresholds and trend validation
|
|
8
|
+
"""
|
|
9
|
+
# Compute rolling volatility metrics
|
|
10
|
+
series = series.sort_index().dropna()
|
|
11
|
+
|
|
12
|
+
returns = series.pct_change()
|
|
13
|
+
series = series[returns != 0]
|
|
14
|
+
series = series[series > 0.1] # we exclude penny stock
|
|
15
|
+
rolling_mean = series.rolling(window, center=True).mean()
|
|
16
|
+
rolling_std = series.rolling(window, center=True).std()
|
|
17
|
+
# Calculate Z-scores
|
|
18
|
+
z_scores = (series - rolling_mean) / rolling_std
|
|
19
|
+
candidates = z_scores.abs() > z_threshold
|
|
20
|
+
|
|
21
|
+
return series[candidates]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def statistical_change_point_detection(
|
|
25
|
+
df: pd.Series,
|
|
26
|
+
pen: int = 10,
|
|
27
|
+
model: str = "l2",
|
|
28
|
+
threshold: float = 0.7,
|
|
29
|
+
min_size: int = 30,
|
|
30
|
+
min_threshold: float = 1.0,
|
|
31
|
+
) -> pd.Series:
|
|
32
|
+
"""Detects abnormal changes in a time series using Pelt change point detection.
|
|
33
|
+
|
|
34
|
+
Analyzes a pandas Series using ruptures' Pelt algorithm to identify statistical
|
|
35
|
+
change points, then validates them using percentage change and minimum value thresholds.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
df: Input time series as pandas Series. Should be numeric and ordered by time.
|
|
39
|
+
pen: Penalty value for change point detection (higher values reduce sensitivity).
|
|
40
|
+
Default: 5.
|
|
41
|
+
model: Cost function model for change point detection. Supported values:
|
|
42
|
+
'l1' (least absolute deviation), 'l2' (least squared deviation).
|
|
43
|
+
Default: 'l1'.
|
|
44
|
+
threshold: Minimum percentage change (0-1) between consecutive segments to
|
|
45
|
+
consider as abnormal. Default: 0.7 (70%).
|
|
46
|
+
min_size: Minimum number of samples between change points. Default: 30.
|
|
47
|
+
min_threshold: Minimum mean value required in both segments to validate
|
|
48
|
+
a change point (avoids flagging low-value fluctuations). Default: 1.0.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
tuple[bool, list[int]]: Contains:
|
|
52
|
+
- bool: True if any validated abnormal changes detected
|
|
53
|
+
- list[int]: Indices of validated change points (empty if none)
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> ts = pd.Series([1.0, 1.1, 1.2, 3.0, 3.1, 3.2])
|
|
57
|
+
>>> detected, points = detect_abnormal_changes(ts, threshold=0.5)
|
|
58
|
+
>>> print(detected, points)
|
|
59
|
+
True [3]
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
Base on https://medium.com/@enginsorhun/decoding-market-shifts-detecting-structural-breaks-ii-2b77bdafd064.
|
|
63
|
+
"""
|
|
64
|
+
changes = []
|
|
65
|
+
|
|
66
|
+
if len(df) < min_size:
|
|
67
|
+
return df.iloc[changes]
|
|
68
|
+
|
|
69
|
+
df = df.sort_index()
|
|
70
|
+
|
|
71
|
+
# Initialize and fit Pelt model
|
|
72
|
+
algo = rpt.Pelt(model=model, min_size=min_size).fit(df.values)
|
|
73
|
+
change_points = algo.predict(pen=pen)
|
|
74
|
+
|
|
75
|
+
# If no changes detected
|
|
76
|
+
if len(change_points) == 0:
|
|
77
|
+
return (False, [])
|
|
78
|
+
|
|
79
|
+
# Calculate percentage changes between segments
|
|
80
|
+
segments = [1] + change_points
|
|
81
|
+
|
|
82
|
+
for i in range(1, len(segments) - 1):
|
|
83
|
+
previous_segment = df.iloc[segments[i - 1] : segments[i] - 1].mean()
|
|
84
|
+
next_segment = df.iloc[segments[i] : segments[i + 1] - 1].mean()
|
|
85
|
+
pct_change = abs(next_segment - previous_segment) / previous_segment
|
|
86
|
+
if next_segment > min_threshold and previous_segment > min_threshold and pct_change > threshold:
|
|
87
|
+
changes.append(segments[i])
|
|
88
|
+
return df.iloc[changes]
|
|
@@ -230,7 +230,7 @@ class StatementWithEstimates:
|
|
|
230
230
|
df[Financial.ENTERPRISE_VALUE.value] = (
|
|
231
231
|
df.get(MarketData.MARKET_CAPITALIZATION.value, empty_series)
|
|
232
232
|
+ df.get(Financial.NET_DEBT.value, empty_series)
|
|
233
|
-
- df.get(Financial.CASH_EQUIVALENTS, empty_series)
|
|
233
|
+
- df.get(Financial.CASH_EQUIVALENTS.value, empty_series)
|
|
234
234
|
)
|
|
235
235
|
|
|
236
236
|
# Calculate a couple of variables
|
|
@@ -251,7 +251,7 @@ class StatementWithEstimates:
|
|
|
251
251
|
df["price_sales_ratio"] = pd.concat(
|
|
252
252
|
[
|
|
253
253
|
yearly_df.get(MarketData.MARKET_CAPITALIZATION.value, yearly_empty_series)
|
|
254
|
-
/ yearly_df.get(Financial.
|
|
254
|
+
/ yearly_df.get(Financial.REVENUE.value, yearly_empty_series),
|
|
255
255
|
quarterly_df.get(MarketData.MARKET_CAPITALIZATION.value, quarterly_empty_series)
|
|
256
256
|
/ quarterly_df.get(Financial.REVENUE.value, quarterly_empty_series)
|
|
257
257
|
.rolling(4, min_periods=1)
|
|
@@ -305,7 +305,7 @@ class StatementWithEstimates:
|
|
|
305
305
|
df.get(Financial.EBIT.value, empty_series) / df.get(Financial.REVENUE.value, empty_series) * 100
|
|
306
306
|
)
|
|
307
307
|
df["net_income_margin"] = (
|
|
308
|
-
df.get(Financial.NET_INCOME_REPORTED, empty_series)
|
|
308
|
+
df.get(Financial.NET_INCOME_REPORTED.value, empty_series)
|
|
309
309
|
/ df.get(Financial.REVENUE.value, empty_series)
|
|
310
310
|
* 100
|
|
311
311
|
)
|
|
@@ -344,7 +344,7 @@ class StatementWithEstimates:
|
|
|
344
344
|
)
|
|
345
345
|
|
|
346
346
|
df["price_to_tangible_bv_ratio"] = df.get(MarketData.CLOSE.value, empty_series) / df.get(
|
|
347
|
-
Financial.TANGIBLE_BOOK_VALUE_PER_SHARE, empty_series
|
|
347
|
+
Financial.TANGIBLE_BOOK_VALUE_PER_SHARE.value, empty_series
|
|
348
348
|
)
|
|
349
349
|
df["cash_shares_ratio"] = df.get(Financial.CASH_AND_SHORT_TERM_INVESTMENT.value, empty_series) / df.get(
|
|
350
350
|
Financial.DILUTED_WEIGHTED_AVG_SHARES.value, empty_series
|
|
@@ -359,7 +359,7 @@ class StatementWithEstimates:
|
|
|
359
359
|
Financial.STOCK_COMPENSATION.value, empty_series
|
|
360
360
|
) / df.get(Financial.EMPLOYEES.value, empty_series)
|
|
361
361
|
|
|
362
|
-
df["net_cash"] = df.get(Financial.CASH_EQUIVALENTS, empty_series) - df.get(
|
|
362
|
+
df["net_cash"] = df.get(Financial.CASH_EQUIVALENTS.value, empty_series) - df.get(
|
|
363
363
|
Financial.CURRENT_LIABILITIES.value, empty_series
|
|
364
364
|
)
|
|
365
365
|
df[Financial.EBIT.value] = df.get(Financial.EBIT.value, empty_series)
|
|
@@ -404,7 +404,6 @@ class StatementWithEstimates:
|
|
|
404
404
|
"stock_compensation_employee_ratio",
|
|
405
405
|
Financial.CAPEX.value,
|
|
406
406
|
Financial.SHARES_OUTSTANDING.value,
|
|
407
|
-
MarketData.MARKET_CAPITALIZATION.value,
|
|
408
407
|
MarketData.CLOSE.value,
|
|
409
408
|
MarketData.MARKET_CAPITALIZATION.value,
|
|
410
409
|
"net_cash",
|
|
@@ -133,6 +133,12 @@ class Loader:
|
|
|
133
133
|
self.errors["missing_data"].append(
|
|
134
134
|
"We could not find any market data covering the financial statement period"
|
|
135
135
|
)
|
|
136
|
+
## TODO We might want to still exclude them from the final df but keep them for the estimate that used these
|
|
137
|
+
## we actually want to keep the market data in the forecast column, because they are used for other statistic computation
|
|
138
|
+
# df.loc[df.index.get_level_values("estimate"), market_data_df.columns.difference(["period_end_date"])] = (
|
|
139
|
+
# None
|
|
140
|
+
# )
|
|
141
|
+
|
|
136
142
|
return df.rename_axis("financial", axis="columns")
|
|
137
143
|
|
|
138
144
|
def _annotate_statement_data(self, df: pd.DataFrame, statement_values: list[Financial]) -> pd.DataFrame:
|
wbfdm/contrib/dsws/client.py
CHANGED
|
@@ -3,7 +3,7 @@ import re
|
|
|
3
3
|
from datetime import date, datetime
|
|
4
4
|
from typing import Generator, List, Optional
|
|
5
5
|
|
|
6
|
-
import DatastreamPy as dsweb
|
|
6
|
+
import DatastreamPy as dsweb # noqa
|
|
7
7
|
import numpy as np
|
|
8
8
|
import pandas as pd
|
|
9
9
|
import pytz
|
|
@@ -13,7 +13,7 @@ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class CachedTokenDataClient(dsweb.DataClient):
|
|
16
|
-
def _get_token(self, isProxy=False):
|
|
16
|
+
def _get_token(self, isProxy=False): # noqa
|
|
17
17
|
if (token := cache.get("dsws_token")) and (token_expiry := cache.get("dsws_token_expiry")):
|
|
18
18
|
self.token = token
|
|
19
19
|
self.tokenExpiry = timezone.make_aware(datetime.fromtimestamp(token_expiry), timezone=pytz.UTC)
|
|
@@ -102,7 +102,7 @@ class Client:
|
|
|
102
102
|
if len(requests_data) > self.MAXIMUM_REQUESTS_PER_BUNDLE:
|
|
103
103
|
raise ValueError(f"number of request exceed {self.MAXIMUM_REQUESTS_PER_BUNDLE}")
|
|
104
104
|
# Construct the requests bundle
|
|
105
|
-
for request_tickers,
|
|
105
|
+
for request_tickers, _ in requests_data:
|
|
106
106
|
# Convert a list of string into a valid string
|
|
107
107
|
converted_ticker = ",".join(request_tickers)
|
|
108
108
|
if "start" in extra_client_kwargs or "end" in extra_client_kwargs:
|
|
@@ -5,6 +5,7 @@ from typing import Iterator
|
|
|
5
5
|
from DatastreamPy import DSUserObjectFault
|
|
6
6
|
from django.conf import settings
|
|
7
7
|
from pandas.tseries.offsets import BDay
|
|
8
|
+
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
8
9
|
from wbcore.contrib.dataloader.dataloaders import Dataloader
|
|
9
10
|
from wbfdm.dataloaders.protocols import MarketDataProtocol
|
|
10
11
|
from wbfdm.dataloaders.types import MarketDataDict
|
|
@@ -23,17 +24,20 @@ FIELD_MAP = {
|
|
|
23
24
|
"volume": "VO",
|
|
24
25
|
"outstanding_shares": "NOSH",
|
|
25
26
|
"market_capitalization": "MV",
|
|
27
|
+
"market_capitalization_consolidated": "MVC",
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class DSWSMarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
30
32
|
def market_data(
|
|
31
33
|
self,
|
|
32
|
-
values: list[MarketData] =
|
|
34
|
+
values: list[MarketData] | None = None,
|
|
33
35
|
from_date: date | None = None,
|
|
34
36
|
to_date: date | None = None,
|
|
35
37
|
exact_date: date | None = None,
|
|
36
38
|
frequency: Frequency = Frequency.DAILY,
|
|
39
|
+
target_currency: str | None = None,
|
|
40
|
+
apply_fx_rate: bool = True,
|
|
37
41
|
**kwargs,
|
|
38
42
|
) -> Iterator[MarketDataDict]:
|
|
39
43
|
"""Get prices for instruments.
|
|
@@ -55,6 +59,12 @@ class DSWSMarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
|
55
59
|
"id",
|
|
56
60
|
)
|
|
57
61
|
}
|
|
62
|
+
instruments = {entity.id: entity for entity in self.entities}
|
|
63
|
+
try:
|
|
64
|
+
target_currency = Currency.objects.get(key=target_currency)
|
|
65
|
+
except Currency.DoesNotExist:
|
|
66
|
+
target_currency = None
|
|
67
|
+
|
|
58
68
|
if (dsws_username := getattr(settings, "REFINITIV_DATASTREAM_USERNAME", None)) and (
|
|
59
69
|
dsws_password := getattr(settings, "REFINITIV_DATASTREAM_PASSWORD", None)
|
|
60
70
|
):
|
|
@@ -80,13 +90,31 @@ class DSWSMarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
|
80
90
|
for row in df.to_dict("records"):
|
|
81
91
|
jsondate = row["Dates"].date()
|
|
82
92
|
external_id = row["Instrument"]
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
fx_rate = 1.0
|
|
94
|
+
if target_currency:
|
|
95
|
+
instrument = instruments[default_lookup[external_id]["id"]]
|
|
96
|
+
if instrument.currency != target_currency:
|
|
97
|
+
with suppress(CurrencyFXRates.DoesNotExist):
|
|
98
|
+
fx_rate = float(instrument.currency.convert(jsondate, target_currency))
|
|
99
|
+
data = dict(fx_rate=fx_rate)
|
|
85
100
|
for market_value in values:
|
|
86
101
|
data[market_value.value] = row.get(FIELD_MAP[market_value.value], None)
|
|
102
|
+
if (
|
|
103
|
+
apply_fx_rate
|
|
104
|
+
and data[market_value.value]
|
|
105
|
+
and market_value.value
|
|
106
|
+
not in [
|
|
107
|
+
MarketData.MARKET_CAPITALIZATION.value,
|
|
108
|
+
MarketData.VOLUME.value,
|
|
109
|
+
MarketData.VWAP.value,
|
|
110
|
+
]
|
|
111
|
+
):
|
|
112
|
+
data[market_value.value] *= fx_rate
|
|
113
|
+
|
|
87
114
|
with suppress(KeyError):
|
|
88
115
|
if default_symbol := default_lookup[external_id].get("symbol", None):
|
|
89
116
|
data["close"] = row[default_symbol]
|
|
117
|
+
|
|
90
118
|
yield MarketDataDict(
|
|
91
119
|
id=f"{default_lookup[external_id]['id']}_{jsondate}",
|
|
92
120
|
valuation_date=jsondate,
|
|
@@ -2,11 +2,14 @@ from datetime import date
|
|
|
2
2
|
from decimal import Decimal
|
|
3
3
|
from typing import Iterator
|
|
4
4
|
|
|
5
|
+
from django.db.models import Case, Value, When
|
|
6
|
+
from django.db.models.functions import Coalesce
|
|
7
|
+
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
5
8
|
from wbcore.contrib.dataloader.dataloaders import Dataloader
|
|
6
9
|
|
|
7
10
|
from wbfdm.dataloaders.protocols import MarketDataProtocol
|
|
8
11
|
from wbfdm.dataloaders.types import MarketDataDict
|
|
9
|
-
from wbfdm.enums import MarketData
|
|
12
|
+
from wbfdm.enums import Frequency, MarketData
|
|
10
13
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
11
14
|
|
|
12
15
|
MarketDataMap = {
|
|
@@ -19,6 +22,7 @@ MarketDataMap = {
|
|
|
19
22
|
"ASK": "net_value",
|
|
20
23
|
"VOLUME": "internal_volume",
|
|
21
24
|
"MARKET_CAPITALIZATION": "market_capitalization",
|
|
25
|
+
"MARKET_CAPITALIZATION_CONSOLIDATED": "market_capitalization",
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
DEFAULT_VALUES = [MarketData[name] for name in MarketDataMap.keys()]
|
|
@@ -33,11 +37,13 @@ def _cast_decimal_to_float(value: float | Decimal) -> float:
|
|
|
33
37
|
class MarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
34
38
|
def market_data(
|
|
35
39
|
self,
|
|
36
|
-
values: list[MarketData] | None =
|
|
40
|
+
values: list[MarketData] | None = None,
|
|
37
41
|
from_date: date | None = None,
|
|
38
42
|
to_date: date | None = None,
|
|
39
43
|
exact_date: date | None = None,
|
|
40
|
-
|
|
44
|
+
frequency: Frequency = Frequency.DAILY,
|
|
45
|
+
target_currency: str | None = None,
|
|
46
|
+
apply_fx_rate: bool = True,
|
|
41
47
|
**kwargs,
|
|
42
48
|
) -> Iterator[MarketDataDict]:
|
|
43
49
|
"""Get prices for instruments.
|
|
@@ -51,16 +57,31 @@ class MarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
|
51
57
|
Returns:
|
|
52
58
|
Iterator[MarketDataDict]: An iterator of dictionaries conforming to the DailyValuationDict.
|
|
53
59
|
"""
|
|
54
|
-
|
|
60
|
+
if not values:
|
|
61
|
+
values = DEFAULT_VALUES
|
|
62
|
+
values_map = {value.name: MarketDataMap[value.name] for value in values if value.name in MarketDataMap}
|
|
63
|
+
calculated = kwargs.get("calculated", None)
|
|
64
|
+
try:
|
|
65
|
+
target_currency = Currency.objects.get(key=target_currency)
|
|
66
|
+
except Currency.DoesNotExist:
|
|
67
|
+
target_currency = None
|
|
68
|
+
fx_rate = (
|
|
69
|
+
Coalesce(
|
|
70
|
+
CurrencyFXRates.get_fx_rates_subquery_for_two_currencies(
|
|
71
|
+
"date", "instrument__currency", target_currency
|
|
72
|
+
),
|
|
73
|
+
Value(Decimal("1")),
|
|
74
|
+
)
|
|
75
|
+
if target_currency
|
|
76
|
+
else Value(Decimal("1"))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
prices = InstrumentPrice.objects.filter(instrument__in=self.entities)
|
|
55
80
|
if calculated is not None:
|
|
56
81
|
prices = prices.filter(calculated=calculated)
|
|
57
82
|
else:
|
|
58
83
|
prices = prices.filter_only_valid_prices()
|
|
59
84
|
|
|
60
|
-
prices = prices.order_by("date")
|
|
61
|
-
if not values:
|
|
62
|
-
values = DEFAULT_VALUES
|
|
63
|
-
values_map = {value.name: MarketDataMap[value.name] for value in values if value.name in MarketDataMap}
|
|
64
85
|
if exact_date:
|
|
65
86
|
prices = prices.filter(date=exact_date)
|
|
66
87
|
else:
|
|
@@ -68,15 +89,27 @@ class MarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
|
68
89
|
prices = prices.filter(date__gte=from_date)
|
|
69
90
|
if to_date:
|
|
70
91
|
prices = prices.filter(date__lte=to_date)
|
|
71
|
-
|
|
92
|
+
prices = prices.annotate_market_data().annotate(
|
|
93
|
+
fx_rate=Case(When(calculated=False, then=fx_rate), default=None)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for row in prices.order_by("date").values(
|
|
72
97
|
"date",
|
|
73
98
|
"instrument",
|
|
74
99
|
"calculated",
|
|
100
|
+
"fx_rate",
|
|
75
101
|
*set(values_map.values()),
|
|
76
102
|
):
|
|
77
103
|
external_id = row.pop("instrument")
|
|
78
104
|
val_date = row.pop("date")
|
|
79
105
|
if row:
|
|
106
|
+
fx_rate = row["fx_rate"]
|
|
107
|
+
if apply_fx_rate and fx_rate is not None:
|
|
108
|
+
if row.get("net_value"):
|
|
109
|
+
row["net_value"] = row["net_value"] * fx_rate
|
|
110
|
+
if row.get("market_capitalization"):
|
|
111
|
+
row["market_capitalization"] = row["market_capitalization"] * float(fx_rate)
|
|
112
|
+
fx_rate = _cast_decimal_to_float(fx_rate)
|
|
80
113
|
yield MarketDataDict(
|
|
81
114
|
id=f"{external_id}_{val_date}",
|
|
82
115
|
valuation_date=val_date,
|
|
@@ -84,5 +117,6 @@ class MarketDataDataloader(MarketDataProtocol, Dataloader):
|
|
|
84
117
|
external_id=external_id,
|
|
85
118
|
source="wbfdm",
|
|
86
119
|
calculated=row["calculated"],
|
|
120
|
+
fx_rate=fx_rate,
|
|
87
121
|
**{MarketData[k].value: _cast_decimal_to_float(row[v]) for k, v in values_map.items()},
|
|
88
122
|
)
|
|
@@ -13,7 +13,7 @@ from wbcore.contrib.currency.models import CurrencyFXRates
|
|
|
13
13
|
from wbfdm.models import Instrument, InstrumentPrice
|
|
14
14
|
|
|
15
15
|
from ..dto import Metric, MetricField, MetricKey
|
|
16
|
-
from ..exceptions import
|
|
16
|
+
from ..exceptions import MetricInvalidParameterError
|
|
17
17
|
from .utils import get_today
|
|
18
18
|
|
|
19
19
|
T = TypeVar("T", bound=Model)
|
|
@@ -94,7 +94,7 @@ class InstrumentMetricBaseBackend(AbstractBackend[Instrument]):
|
|
|
94
94
|
[val_date, (get_today() - pd.tseries.offsets.BDay(1)).date()]
|
|
95
95
|
) # ensure that value date is at least lower than today (otherwise, we might compute performance for intraday, which we do not want yet
|
|
96
96
|
else:
|
|
97
|
-
raise
|
|
97
|
+
raise MetricInvalidParameterError()
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
class BaseDataloader:
|
|
@@ -3,7 +3,7 @@ from datetime import date
|
|
|
3
3
|
from typing import Generator
|
|
4
4
|
|
|
5
5
|
import pandas as pd
|
|
6
|
-
from django.db.models import
|
|
6
|
+
from django.db.models import Sum
|
|
7
7
|
from wbcore.serializers.fields.number import DisplayMode
|
|
8
8
|
|
|
9
9
|
from wbfdm.enums import Financial, PeriodType, SeriesType
|
|
@@ -11,7 +11,7 @@ from wbfdm.models import Instrument, InstrumentPrice
|
|
|
11
11
|
|
|
12
12
|
from ..decorators import register
|
|
13
13
|
from ..dto import Metric, MetricField, MetricKey
|
|
14
|
-
from ..exceptions import
|
|
14
|
+
from ..exceptions import MetricInvalidParameterError
|
|
15
15
|
from .base import BaseDataloader, InstrumentMetricBaseBackend
|
|
16
16
|
|
|
17
17
|
STATISTICS_METRIC = MetricKey(
|
|
@@ -21,7 +21,6 @@ STATISTICS_METRIC = MetricKey(
|
|
|
21
21
|
MetricField(
|
|
22
22
|
key="revenue_y_1",
|
|
23
23
|
label="Revenue Y-1",
|
|
24
|
-
aggregate=Sum,
|
|
25
24
|
list_display_kwargs={"show": "open"},
|
|
26
25
|
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
27
26
|
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
@@ -29,14 +28,12 @@ STATISTICS_METRIC = MetricKey(
|
|
|
29
28
|
MetricField(
|
|
30
29
|
key="revenue_y0",
|
|
31
30
|
label="Revenue Y0",
|
|
32
|
-
aggregate=Sum,
|
|
33
31
|
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
34
32
|
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
35
33
|
),
|
|
36
34
|
MetricField(
|
|
37
35
|
key="revenue_y1",
|
|
38
36
|
label="Revenue Y1",
|
|
39
|
-
aggregate=Sum,
|
|
40
37
|
list_display_kwargs={"show": "open"},
|
|
41
38
|
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
42
39
|
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
@@ -44,20 +41,17 @@ STATISTICS_METRIC = MetricKey(
|
|
|
44
41
|
MetricField(
|
|
45
42
|
key="market_capitalization",
|
|
46
43
|
label="Market Capitalization",
|
|
47
|
-
aggregate=Sum,
|
|
48
44
|
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
49
45
|
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
50
46
|
),
|
|
51
47
|
MetricField(
|
|
52
48
|
key="price",
|
|
53
49
|
label="Price",
|
|
54
|
-
aggregate=Avg,
|
|
55
50
|
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
56
51
|
),
|
|
57
52
|
MetricField(
|
|
58
53
|
key="volume_50d",
|
|
59
54
|
label="Volume 50D",
|
|
60
|
-
aggregate=Avg,
|
|
61
55
|
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
62
56
|
),
|
|
63
57
|
],
|
|
@@ -65,9 +59,49 @@ STATISTICS_METRIC = MetricKey(
|
|
|
65
59
|
|
|
66
60
|
STATISTICS_METRIC_USD = MetricKey(
|
|
67
61
|
key="statistic_usd",
|
|
68
|
-
label=
|
|
69
|
-
subfields=
|
|
70
|
-
|
|
62
|
+
label="Statistic (USD)",
|
|
63
|
+
subfields=[
|
|
64
|
+
MetricField(
|
|
65
|
+
key="revenue_y_1",
|
|
66
|
+
label="Revenue Y-1",
|
|
67
|
+
aggregate=Sum,
|
|
68
|
+
list_display_kwargs={"show": "open"},
|
|
69
|
+
decorators=[{"position": "left", "value": "$"}],
|
|
70
|
+
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
71
|
+
),
|
|
72
|
+
MetricField(
|
|
73
|
+
key="revenue_y0",
|
|
74
|
+
label="Revenue Y0",
|
|
75
|
+
aggregate=Sum,
|
|
76
|
+
decorators=[{"position": "left", "value": "$"}],
|
|
77
|
+
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
78
|
+
),
|
|
79
|
+
MetricField(
|
|
80
|
+
key="revenue_y1",
|
|
81
|
+
label="Revenue Y1",
|
|
82
|
+
aggregate=Sum,
|
|
83
|
+
list_display_kwargs={"show": "open"},
|
|
84
|
+
decorators=[{"position": "left", "value": "$"}],
|
|
85
|
+
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
86
|
+
),
|
|
87
|
+
MetricField(
|
|
88
|
+
key="market_capitalization",
|
|
89
|
+
label="Market Capitalization",
|
|
90
|
+
aggregate=Sum,
|
|
91
|
+
decorators=[{"position": "left", "value": "$"}],
|
|
92
|
+
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
93
|
+
),
|
|
94
|
+
MetricField(
|
|
95
|
+
key="price",
|
|
96
|
+
label="Price",
|
|
97
|
+
decorators=[{"position": "left", "value": "{{currency_symbol}}"}],
|
|
98
|
+
),
|
|
99
|
+
MetricField(
|
|
100
|
+
key="volume_50d",
|
|
101
|
+
label="Volume 50D",
|
|
102
|
+
serializer_kwargs={"display_mode": DisplayMode.SHORTENED},
|
|
103
|
+
),
|
|
104
|
+
],
|
|
71
105
|
)
|
|
72
106
|
|
|
73
107
|
|
|
@@ -137,7 +171,7 @@ class Dataloader(BaseDataloader):
|
|
|
137
171
|
)
|
|
138
172
|
fx_rate = df_price["fx_rate"]
|
|
139
173
|
df = pd.concat([df_revenue, df_price.drop("fx_rate", axis=1)], axis=1)
|
|
140
|
-
for key in ["revenue_y_1", "revenue_y0", "revenue_y1", "market_capitalization"
|
|
174
|
+
for key in ["revenue_y_1", "revenue_y0", "revenue_y1", "market_capitalization"]:
|
|
141
175
|
if key in df.columns:
|
|
142
176
|
df[key] = df[key] / fx_rate
|
|
143
177
|
|
|
@@ -173,7 +207,7 @@ class InstrumentFinancialStatisticsMetricBackend(InstrumentMetricBaseBackend):
|
|
|
173
207
|
elif self.val_date:
|
|
174
208
|
with suppress(InstrumentPrice.DoesNotExist):
|
|
175
209
|
return instrument.valuations.filter(date__lte=self.val_date).latest("date").date
|
|
176
|
-
raise
|
|
210
|
+
raise MetricInvalidParameterError()
|
|
177
211
|
|
|
178
212
|
|
|
179
213
|
@register(move_first=True)
|
wbfdm/contrib/metric/dispatch.py
CHANGED
|
@@ -2,6 +2,8 @@ from contextlib import suppress
|
|
|
2
2
|
from datetime import date
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
+
from wbfdm.contrib.metric.signals import instrument_metric_updated
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
def compute_metrics(val_date: date, key: str | None = None, basket: Any | None = None, **kwargs):
|
|
7
9
|
"""
|
|
@@ -21,3 +23,4 @@ def compute_metrics(val_date: date, key: str | None = None, basket: Any | None =
|
|
|
21
23
|
with suppress(KeyError):
|
|
22
24
|
orchestrator = MetricOrchestrator(val_date, key=key, basket=basket, **kwargs)
|
|
23
25
|
orchestrator.process()
|
|
26
|
+
instrument_metric_updated.send(sender=basket.__class__, basket=basket, key=key, val_date=val_date)
|
wbfdm/contrib/metric/filters.py
CHANGED
|
@@ -7,7 +7,25 @@ from wbfdm.contrib.metric.models import InstrumentMetric
|
|
|
7
7
|
from .registry import backend_registry
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def get_metrics_content_type(request, view):
|
|
11
|
+
return {
|
|
12
|
+
"id__in": list(
|
|
13
|
+
InstrumentMetric.objects.values_list("basket_content_type", flat=True).distinct("basket_content_type")
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
10
18
|
class InstrumentMetricFilterSet(filters.FilterSet):
|
|
19
|
+
parent_metric = filters.ModelChoiceFilter(
|
|
20
|
+
label="Parent",
|
|
21
|
+
queryset=InstrumentMetric.objects.all(),
|
|
22
|
+
endpoint=InstrumentMetric.get_representation_endpoint(),
|
|
23
|
+
value_key=InstrumentMetric.get_representation_value_key(),
|
|
24
|
+
label_key=InstrumentMetric.get_representation_label_key(),
|
|
25
|
+
hidden=True,
|
|
26
|
+
)
|
|
27
|
+
parent_metric__isnull = filters.BooleanFilter(field_name="parent_metric", lookup_expr="isnull", hidden=True)
|
|
28
|
+
|
|
11
29
|
key = filters.ChoiceFilter(choices=backend_registry.get_choices(), label="Key")
|
|
12
30
|
basket_content_type = filters.ModelChoiceFilter(
|
|
13
31
|
queryset=ContentType.objects.all(),
|
|
@@ -15,6 +33,7 @@ class InstrumentMetricFilterSet(filters.FilterSet):
|
|
|
15
33
|
value_key="id",
|
|
16
34
|
label_key="{{app_label}} | {{model}}",
|
|
17
35
|
label=_("Basket Content Type"),
|
|
36
|
+
filter_params=get_metrics_content_type,
|
|
18
37
|
)
|
|
19
38
|
|
|
20
39
|
class Meta:
|
wbfdm/contrib/metric/models.py
CHANGED
|
@@ -48,6 +48,12 @@ class InstrumentMetric(models.Model):
|
|
|
48
48
|
self.basket_repr = str(self.basket)
|
|
49
49
|
super().save(*args, **kwargs)
|
|
50
50
|
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
repr = f"{self.basket} - {self.key}"
|
|
53
|
+
if self.date:
|
|
54
|
+
repr += f"({self.date})"
|
|
55
|
+
return repr
|
|
56
|
+
|
|
51
57
|
@classmethod
|
|
52
58
|
def update_or_create_from_metric(cls, metric: Metric, parent_instrument_metric: Self | None = None):
|
|
53
59
|
"""
|
|
@@ -8,7 +8,7 @@ from tqdm import tqdm
|
|
|
8
8
|
|
|
9
9
|
from .backends.base import AbstractBackend
|
|
10
10
|
from .dto import Metric
|
|
11
|
-
from .exceptions import
|
|
11
|
+
from .exceptions import MetricInvalidParameterError
|
|
12
12
|
from .models import InstrumentMetric
|
|
13
13
|
from .registry import backend_registry
|
|
14
14
|
|
|
@@ -73,9 +73,9 @@ class MetricOrchestrator:
|
|
|
73
73
|
if debug:
|
|
74
74
|
# if debug mode is enabled, we wrap the parameters list into a tqdm generator
|
|
75
75
|
parameters = tqdm(parameters)
|
|
76
|
-
for
|
|
77
|
-
with suppress(
|
|
78
|
-
yield from
|
|
76
|
+
for param in parameters:
|
|
77
|
+
with suppress(MetricInvalidParameterError):
|
|
78
|
+
yield from param[0].compute_metrics(param[1])
|
|
79
79
|
|
|
80
80
|
def process(self, debug: bool = False):
|
|
81
81
|
"""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from django.db.models.signals import ModelSignal
|
|
2
|
+
|
|
3
|
+
# this signal is triggered whenever all instruments metrics are updated. Temporary solution until we rework the framework for more dynamicity
|
|
4
|
+
|
|
5
|
+
instrument_metric_updated = ModelSignal(
|
|
6
|
+
use_caching=True
|
|
7
|
+
) # the sender model is the type model class being updated (e.g. Instrument), expect a "basket", "key" and "val_date" keyword argument (null if all are updated)
|
wbfdm/contrib/metric/tasks.py
CHANGED
|
@@ -3,7 +3,7 @@ from datetime import date
|
|
|
3
3
|
from celery import shared_task
|
|
4
4
|
from django.contrib.contenttypes.models import ContentType
|
|
5
5
|
|
|
6
|
-
from wbfdm.contrib.metric.
|
|
6
|
+
from wbfdm.contrib.metric.dispatch import compute_metrics
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@shared_task(queue="portfolio")
|
|
@@ -24,5 +24,4 @@ def compute_metrics_as_task(
|
|
|
24
24
|
basket = None
|
|
25
25
|
if basket_content_type_id and basket_id:
|
|
26
26
|
basket = ContentType.objects.get(id=basket_content_type_id).get_object_for_this_type(pk=basket_id)
|
|
27
|
-
|
|
28
|
-
routine.process()
|
|
27
|
+
compute_metrics(val_date=val_date, key=key, basket=basket, **kwargs)
|