wbfdm 1.49.5__py2.py3-none-any.whl → 1.59.4__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. wbfdm/admin/exchanges.py +1 -1
  2. wbfdm/admin/instruments.py +3 -2
  3. wbfdm/analysis/financial_analysis/change_point_detection.py +88 -0
  4. wbfdm/analysis/financial_analysis/statement_with_estimates.py +5 -6
  5. wbfdm/analysis/financial_analysis/utils.py +6 -0
  6. wbfdm/contrib/dsws/client.py +3 -3
  7. wbfdm/contrib/dsws/dataloaders/market_data.py +31 -3
  8. wbfdm/contrib/internal/dataloaders/market_data.py +43 -9
  9. wbfdm/contrib/metric/backends/base.py +2 -2
  10. wbfdm/contrib/metric/backends/statistics.py +47 -13
  11. wbfdm/contrib/metric/dispatch.py +3 -0
  12. wbfdm/contrib/metric/exceptions.py +1 -1
  13. wbfdm/contrib/metric/filters.py +19 -0
  14. wbfdm/contrib/metric/models.py +6 -0
  15. wbfdm/contrib/metric/orchestrators.py +4 -4
  16. wbfdm/contrib/metric/signals.py +7 -0
  17. wbfdm/contrib/metric/tasks.py +2 -3
  18. wbfdm/contrib/metric/viewsets/configs/display.py +2 -2
  19. wbfdm/contrib/metric/viewsets/mixins.py +6 -6
  20. wbfdm/contrib/msci/client.py +6 -2
  21. wbfdm/contrib/qa/database_routers.py +1 -1
  22. wbfdm/contrib/qa/dataloaders/adjustments.py +2 -1
  23. wbfdm/contrib/qa/dataloaders/corporate_actions.py +2 -1
  24. wbfdm/contrib/qa/dataloaders/financials.py +19 -1
  25. wbfdm/contrib/qa/dataloaders/fx_rates.py +86 -0
  26. wbfdm/contrib/qa/dataloaders/market_data.py +29 -40
  27. wbfdm/contrib/qa/dataloaders/officers.py +1 -1
  28. wbfdm/contrib/qa/dataloaders/statements.py +18 -3
  29. wbfdm/contrib/qa/jinja2/qa/sql/ibes/financials.sql +1 -1
  30. wbfdm/contrib/qa/sync/exchanges.py +2 -1
  31. wbfdm/contrib/qa/sync/utils.py +76 -17
  32. wbfdm/dataloaders/protocols.py +12 -1
  33. wbfdm/dataloaders/proxies.py +15 -1
  34. wbfdm/dataloaders/types.py +7 -1
  35. wbfdm/enums.py +2 -0
  36. wbfdm/factories/instruments.py +4 -2
  37. wbfdm/figures/financials/financial_analysis_charts.py +2 -8
  38. wbfdm/filters/classifications.py +2 -2
  39. wbfdm/filters/financials.py +9 -18
  40. wbfdm/filters/financials_analysis.py +36 -16
  41. wbfdm/filters/instrument_prices.py +8 -5
  42. wbfdm/filters/instruments.py +21 -7
  43. wbfdm/import_export/backends/cbinsights/utils/client.py +8 -8
  44. wbfdm/import_export/backends/refinitiv/utils/controller.py +1 -1
  45. wbfdm/import_export/handlers/instrument.py +160 -104
  46. wbfdm/import_export/handlers/option.py +2 -2
  47. wbfdm/import_export/parsers/cbinsights/equities.py +2 -3
  48. wbfdm/jinja2.py +2 -1
  49. wbfdm/locale/de/LC_MESSAGES/django.mo +0 -0
  50. wbfdm/locale/de/LC_MESSAGES/django.po +257 -0
  51. wbfdm/locale/en/LC_MESSAGES/django.mo +0 -0
  52. wbfdm/locale/en/LC_MESSAGES/django.po +255 -0
  53. wbfdm/locale/fr/LC_MESSAGES/django.mo +0 -0
  54. wbfdm/locale/fr/LC_MESSAGES/django.po +257 -0
  55. wbfdm/migrations/0031_exchange_apply_round_lot_size_and_more.py +23 -0
  56. wbfdm/migrations/0032_alter_instrumentprice_outstanding_shares.py +18 -0
  57. wbfdm/migrations/0033_alter_controversy_review.py +18 -0
  58. wbfdm/migrations/0034_alter_instrumentlist_instrument_list_type.py +18 -0
  59. wbfdm/models/esg/controversies.py +19 -23
  60. wbfdm/models/exchanges/exchanges.py +8 -4
  61. wbfdm/models/fields.py +2 -2
  62. wbfdm/models/fk_fields.py +3 -3
  63. wbfdm/models/instruments/instrument_lists.py +1 -0
  64. wbfdm/models/instruments/instrument_prices.py +8 -1
  65. wbfdm/models/instruments/instrument_relationships.py +3 -0
  66. wbfdm/models/instruments/instruments.py +139 -26
  67. wbfdm/models/instruments/llm/create_instrument_news_relationships.py +29 -22
  68. wbfdm/models/instruments/mixin/financials_computed.py +0 -4
  69. wbfdm/models/instruments/mixin/financials_serializer_fields.py +118 -118
  70. wbfdm/models/instruments/mixin/instruments.py +7 -4
  71. wbfdm/models/instruments/options.py +6 -0
  72. wbfdm/models/instruments/private_equities.py +3 -0
  73. wbfdm/models/instruments/querysets.py +138 -37
  74. wbfdm/models/instruments/utils.py +5 -0
  75. wbfdm/serializers/exchanges.py +1 -0
  76. wbfdm/serializers/instruments/__init__.py +1 -0
  77. wbfdm/serializers/instruments/instruments.py +9 -2
  78. wbfdm/serializers/instruments/mixins.py +3 -3
  79. wbfdm/tasks.py +13 -2
  80. wbfdm/tests/analysis/financial_analysis/test_statement_with_estimates.py +0 -1
  81. wbfdm/tests/models/test_instrument_prices.py +0 -14
  82. wbfdm/tests/models/test_instruments.py +21 -9
  83. wbfdm/tests/models/test_queryset.py +89 -0
  84. wbfdm/viewsets/configs/display/exchanges.py +1 -1
  85. wbfdm/viewsets/configs/display/financial_summary.py +2 -2
  86. wbfdm/viewsets/configs/display/instrument_prices.py +2 -70
  87. wbfdm/viewsets/configs/display/instruments.py +3 -4
  88. wbfdm/viewsets/configs/display/instruments_relationships.py +3 -1
  89. wbfdm/viewsets/configs/display/prices.py +1 -0
  90. wbfdm/viewsets/configs/display/statement_with_estimates.py +1 -2
  91. wbfdm/viewsets/configs/endpoints/classifications.py +0 -12
  92. wbfdm/viewsets/configs/endpoints/instrument_prices.py +4 -23
  93. wbfdm/viewsets/configs/titles/instrument_prices.py +2 -1
  94. wbfdm/viewsets/esg.py +2 -2
  95. wbfdm/viewsets/financial_analysis/financial_metric_analysis.py +2 -2
  96. wbfdm/viewsets/financial_analysis/financial_ratio_analysis.py +1 -1
  97. wbfdm/viewsets/financial_analysis/financial_summary.py +6 -6
  98. wbfdm/viewsets/financial_analysis/statement_with_estimates.py +7 -3
  99. wbfdm/viewsets/instruments/financials_analysis.py +9 -12
  100. wbfdm/viewsets/instruments/instrument_prices.py +9 -9
  101. wbfdm/viewsets/instruments/instruments.py +9 -7
  102. wbfdm/viewsets/instruments/utils.py +3 -3
  103. wbfdm/viewsets/market_data.py +1 -1
  104. wbfdm/viewsets/prices.py +5 -0
  105. wbfdm/viewsets/statements/statements.py +7 -3
  106. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/METADATA +2 -1
  107. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/RECORD +108 -95
  108. {wbfdm-1.49.5.dist-info → wbfdm-1.59.4.dist-info}/WHEEL +1 -1
  109. wbfdm/menu.py +0 -11
wbfdm/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
  },
@@ -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", "is_investable_universe", "is_primary"),
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.GROSS_PROFIT.value, yearly_empty_series),
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:
@@ -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, request_symbols in requests_data:
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] = [MarketData.CLOSE],
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
- data = {}
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 = [MarketData.CLOSE],
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
- calculated: bool | None = None,
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
- prices = InstrumentPrice.objects.filter(instrument__in=self.entities).annotate_market_data() # type: ignore
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
- for row in prices.filter_only_valid_prices().values(
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 MetricInvalidParameterException
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 MetricInvalidParameterException()
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 Avg, Sum
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 MetricInvalidParameterException
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=STATISTICS_METRIC.label,
69
- subfields=STATISTICS_METRIC.subfields,
70
- additional_prefixes=STATISTICS_METRIC.additional_prefixes,
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", "price"]:
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 MetricInvalidParameterException()
210
+ raise MetricInvalidParameterError()
177
211
 
178
212
 
179
213
  @register(move_first=True)
@@ -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)
@@ -1,4 +1,4 @@
1
- class MetricInvalidParameterException(Exception):
1
+ class MetricInvalidParameterError(Exception):
2
2
  """
3
3
  If instantiated backend parameters are invalid, the orchestrator or backend itself are expected to raise that exception
4
4
  """
@@ -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:
@@ -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 MetricInvalidParameterException
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 parameters in parameters:
77
- with suppress(MetricInvalidParameterException):
78
- yield from parameters[0].compute_metrics(parameters[1])
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)
@@ -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.orchestrators import MetricOrchestrator
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
- routine = MetricOrchestrator(val_date, key=key, basket=basket, **kwargs)
28
- routine.process()
27
+ compute_metrics(val_date=val_date, key=key, basket=basket, **kwargs)