wbportfolio 1.54.22__py2.py3-none-any.whl → 1.55.0__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.

Potentially problematic release.


This version of wbportfolio might be problematic. Click here for more details.

Files changed (79) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,23 +1,41 @@
1
1
  import datetime as dt
2
+ from collections import defaultdict
3
+ from contextlib import suppress
4
+ from decimal import Decimal
2
5
 
3
- import numpy as np
4
6
  import pandas as pd
5
7
  import plotly.express as px
6
8
  import plotly.graph_objects as go
7
- from django.db.models import QuerySet
8
- from django.shortcuts import get_object_or_404
9
+ from django.db.models import F, Prefetch
9
10
  from django.utils.functional import cached_property
11
+ from plotly.subplots import make_subplots
12
+ from rest_framework.request import Request
10
13
  from wbcore import viewsets
14
+ from wbcore.contrib.currency.models import Currency
15
+ from wbcore.contrib.geography.models import Geography
11
16
  from wbcore.contrib.io.viewsets import ExportPandasAPIViewSet
17
+ from wbcore.contrib.pandas import fields as pf
12
18
  from wbcore.filters import DjangoFilterBackend
13
- from wbcore.pandas import fields as pf
19
+ from wbcore.utils.date import get_date_interval_from_request
20
+ from wbcore.utils.figures import (
21
+ get_default_timeserie_figure,
22
+ get_hovertemplate_timeserie,
23
+ )
24
+ from wbcore.utils.strings import format_number
14
25
  from wbfdm.models import (
26
+ Classification,
15
27
  ClassificationGroup,
16
28
  Instrument,
17
29
  InstrumentClassificationThroughModel,
30
+ InstrumentType,
18
31
  )
19
32
 
20
- from wbportfolio.filters.assets import DistributionFilter
33
+ from wbportfolio.filters.assets import (
34
+ AssetPositionUnderlyingInstrumentChartFilter,
35
+ CompositionContributionChartFilter,
36
+ ContributionChartFilter,
37
+ DistributionFilter,
38
+ )
21
39
  from wbportfolio.models import (
22
40
  AssetPosition,
23
41
  AssetPositionGroupBy,
@@ -25,27 +43,46 @@ from wbportfolio.models import (
25
43
  PortfolioRole,
26
44
  )
27
45
 
28
- from ...constants import EQUITY_TYPE_KEYS
29
46
  from ..configs.buttons.assets import (
30
47
  DistributionChartButtonConfig,
31
48
  DistributionTableButtonConfig,
32
49
  )
33
50
  from ..configs.display.assets import DistributionTableDisplayConfig
34
51
  from ..configs.endpoints.assets import (
52
+ AssetPositionUnderlyingInstrumentChartEndpointConfig,
53
+ ContributorPortfolioChartEndpointConfig,
35
54
  DistributionChartEndpointConfig,
36
55
  DistributionTableEndpointConfig,
37
56
  )
38
57
  from ..configs.titles.assets import (
58
+ AssetPositionUnderlyingInstrumentChartTitleConfig,
59
+ ContributorPortfolioChartTitleConfig,
39
60
  DistributionChartTitleConfig,
40
61
  DistributionTableTitleConfig,
41
62
  )
63
+ from ..mixins import UserPortfolioRequestPermissionMixin
42
64
 
43
65
 
44
- class AbstractDistributionMixin:
45
- AUTORESIZE = False
66
+ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
46
67
  queryset = AssetPosition.objects.all()
47
68
  filterset_class = DistributionFilter
48
69
  filter_backends = (DjangoFilterBackend,)
70
+ request: Request
71
+
72
+ @cached_property
73
+ def group_by(self) -> AssetPositionGroupBy:
74
+ try:
75
+ return AssetPositionGroupBy(self.request.GET.get("group_by", "classification"))
76
+ except ValueError:
77
+ return AssetPositionGroupBy.INDUSTRY
78
+
79
+ @cached_property
80
+ def val_date(self) -> dt.date:
81
+ if validity_date_repr := self.request.GET.get("date"):
82
+ val_date = dt.datetime.strptime(validity_date_repr, "%Y-%m-%d")
83
+ else:
84
+ val_date = dt.date.today()
85
+ return val_date
49
86
 
50
87
  @cached_property
51
88
  def classification_group(self):
@@ -55,71 +92,95 @@ class AbstractDistributionMixin:
55
92
  return ClassificationGroup.objects.get(is_primary=True)
56
93
 
57
94
  @cached_property
58
- def classification_field_names(self):
59
- return [f"classification__{field_name}__name" for field_name in self.classification_group.get_fields_names()]
60
-
61
- @cached_property
62
- def classification_levels_representation(self):
63
- return self.classification_group.get_levels_representation()
95
+ def classification_height(self) -> int:
96
+ return int(self.request.GET.get("group_by_classification_height", "0"))
64
97
 
65
98
  @cached_property
66
- def classification_columns_map(self):
67
- return dict(
68
- zip(["classification__name", *self.classification_field_names], self.classification_levels_representation)
69
- )
70
-
71
- def _generate_classification_df(self, queryset):
72
- df = pd.DataFrame(
73
- queryset.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS).values(
74
- "weighting", "underlying_instrument"
75
- ),
76
- columns=["weighting", "underlying_instrument"],
77
- )
78
- df.underlying_instrument = df.underlying_instrument.map(
79
- dict(
80
- Instrument.objects.filter(id__in=df.underlying_instrument)
81
- .annotate_base_data()
82
- .values_list("id", "root")
83
- )
84
- )
85
- df = df.groupby("underlying_instrument").sum()
86
- classifications = InstrumentClassificationThroughModel.objects.filter(
87
- classification__group=self.classification_group, instrument__in=df.index
88
- )
89
- df_classification = pd.DataFrame(
90
- classifications.values(
91
- "instrument",
92
- "classification__name",
93
- *self.classification_field_names,
94
- )
95
- )
96
- if df_classification.empty:
97
- return pd.DataFrame()
98
- return pd.concat([df, df_classification.groupby("instrument").first()], axis=1).replace(
99
- [np.inf, -np.inf, np.nan], "N/A"
100
- )
99
+ def columns_map(self) -> dict:
100
+ if self.group_by == AssetPositionGroupBy.INDUSTRY:
101
+ columns = {}
102
+ level_representations = self.classification_group.get_levels_representation()
103
+ for key, label in zip(
104
+ reversed(self.classification_group.get_fields_names(sep="_")), reversed(level_representations[1:])
105
+ ):
106
+ columns[key] = label
107
+ columns["label"] = "Classification"
108
+ columns["equity"] = "Instrument"
109
+ else:
110
+ columns = {"label": "Label"}
111
+ return columns
101
112
 
102
113
  def get_queryset(self):
103
- portfolio = get_object_or_404(Portfolio, id=self.kwargs["portfolio_id"])
104
- if (
105
- PortfolioRole.is_analyst(self.request.user.profile, portfolio=portfolio)
106
- or self.request.user.profile.is_internal
107
- ):
108
- return super().get_queryset().filter(portfolio=portfolio)
114
+ profile = self.request.user.profile
115
+ if PortfolioRole.is_analyst(profile, portfolio=self.portfolio) or profile.is_internal:
116
+ return AssetPosition.objects.filter(portfolio=self.portfolio)
109
117
  return AssetPosition.objects.none()
110
118
 
111
- @staticmethod
112
- def dataframe_group_by_instrument(df: pd.DataFrame) -> pd.DataFrame:
113
- if df.empty:
114
- return pd.DataFrame()
115
- return df.groupby("aggregated_title").sum().sort_values(by="weighting", ascending=False)
119
+ def get_dataframe(self, request, queryset, **kwargs) -> pd.DataFrame:
120
+ instruments = defaultdict(Decimal)
121
+ for asset in self.portfolio.get_positions(self.val_date):
122
+ if self.group_by != AssetPositionGroupBy.INDUSTRY:
123
+ group_field = getattr(asset.underlying_instrument, self.group_by.value)
124
+ else:
125
+ group_field = asset.underlying_instrument.get_root()
126
+ group_field = getattr(group_field, "id", group_field)
127
+ instruments[group_field] += asset.weighting
128
+ df = pd.DataFrame.from_dict(instruments, orient="index", columns=["weighting"])
129
+ if self.group_by == AssetPositionGroupBy.INDUSTRY:
130
+ classifications = InstrumentClassificationThroughModel.objects.filter(
131
+ instrument__in=instruments.keys(), classification__group=self.classification_group
132
+ )
133
+ field_names = {
134
+ field_name.replace("__", "_"): F(f"classification__{field_name}__name")
135
+ for field_name in self.classification_group.get_fields_names()
136
+ }
137
+ classifications = (
138
+ classifications.annotate(**field_names)
139
+ .select_related(
140
+ *[f"classification__{field_name}" for field_name in self.classification_group.get_fields_names()]
141
+ )
142
+ .prefetch_related(
143
+ "tags",
144
+ Prefetch("instrument", queryset=Instrument.objects.filter(classifications_through__isnull=False)),
145
+ )
146
+ )
147
+ df_classification = (
148
+ pd.DataFrame(
149
+ classifications.values_list("instrument", "classification", *field_names.keys()),
150
+ columns=["id", "classification", *field_names.keys()],
151
+ )
152
+ .groupby("id")
153
+ .first()
154
+ )
155
+ df = pd.concat([df, df_classification], axis=1)
156
+ if df.weighting.sum(): # normalize
157
+ df.weighting /= df.weighting.sum()
158
+ return df.reset_index(names="id")
116
159
 
117
- def dataframe_groupby_with_class_method(self, qs: QuerySet, class_method: classmethod):
118
- df = pd.DataFrame()
119
- if qs.exists():
120
- df = self.dataframe_group_by_instrument(
121
- pd.DataFrame(class_method(qs).values("weighting", "aggregated_title"))
160
+ def manipulate_dataframe(self, df):
161
+ if self.group_by == AssetPositionGroupBy.INDUSTRY:
162
+ if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=self.portfolio):
163
+ df["equity"] = ""
164
+ else:
165
+ df["equity"] = df["id"].map(
166
+ dict(Instrument.objects.filter(id__in=df["id"]).values_list("id", "computed_str"))
167
+ )
168
+ df["label"] = df["classification"].map(
169
+ dict(Classification.objects.filter(id__in=df["classification"].dropna()).values_list("id", "name"))
170
+ )
171
+ elif self.group_by == AssetPositionGroupBy.CASH:
172
+ df.loc[df["id"] == True, "label"] = "Cash"
173
+ df.loc[df["id"] == False, "label"] = "Non-Cash"
174
+ elif self.group_by == AssetPositionGroupBy.COUNTRY:
175
+ df["label"] = df["id"].map(dict(Geography.objects.filter(id__in=df["id"]).values_list("id", "name")))
176
+ elif self.group_by == AssetPositionGroupBy.CURRENCY:
177
+ currencies = dict(map(lambda o: (o.id, str(o)), Currency.objects.filter(id__in=df["id"])))
178
+ df["label"] = df["id"].map(currencies)
179
+ elif self.group_by == AssetPositionGroupBy.INSTRUMENT_TYPE:
180
+ df["label"] = df["id"].map(
181
+ dict(InstrumentType.objects.filter(id__in=df["id"]).values_list("id", "short_name"))
122
182
  )
183
+ df.sort_values(by="weighting", ascending=False, inplace=True)
123
184
  return df
124
185
 
125
186
 
@@ -142,39 +203,17 @@ class DistributionChartViewSet(AbstractDistributionMixin, viewsets.ChartViewSet)
142
203
 
143
204
  def get_plotly(self, queryset):
144
205
  fig = go.Figure()
145
- group_by = self.request.GET.get("group_by", "COUNTRY")
146
- class_method = AssetPositionGroupBy.get_class_method_group_by(name=group_by)
147
- queryset_without_cash = queryset.exclude(underlying_instrument__is_cash=True)
148
- if group_by not in ["INDUSTRY", "CURRENCY"]:
149
- df = self.dataframe_groupby_with_class_method(qs=queryset_without_cash, class_method=class_method)
150
- fig = self.pie_chart(df=df)
151
- elif group_by == "CURRENCY":
152
- df = self.dataframe_groupby_with_class_method(qs=queryset, class_method=class_method)
153
- fig = self.pie_chart(df=df)
154
- else:
155
- df = self._generate_classification_df(queryset_without_cash)
156
- if not df.empty:
157
- df["weighting"] = df.weighting / df.weighting.sum()
158
- df.weighting = df.weighting.astype("float")
159
- df = df.reset_index().rename(
160
- columns={**self.classification_columns_map, "weighting": "weight", "index": "Equity"}
161
- )
162
-
163
- levels = [*self.classification_levels_representation[::-1], "Equity"]
164
- df["Equity"] = df["Equity"].map(
165
- dict(Instrument.objects.filter(id__in=df["Equity"]).values_list("id", "name_repr"))
166
- )
167
- portfolio = Portfolio.objects.get(id=self.kwargs["portfolio_id"])
168
- if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=portfolio):
169
- del df["Equity"]
170
- levels.remove("Equity")
171
- fig = px.sunburst(
172
- df,
173
- path=levels,
174
- values="weight",
175
- hover_data={"weight": ":.2%"},
176
- )
177
- fig.update_traces(hovertemplate="<b>%{label}</b><br>Weight = %{customdata:.3p}")
206
+ df = self.manipulate_dataframe(self.get_dataframe(self.request, queryset))
207
+ df = df.dropna(how="any")
208
+ if not df.empty:
209
+ levels = list(self.columns_map.keys())
210
+ fig = px.sunburst(
211
+ df,
212
+ path=levels,
213
+ values="weighting",
214
+ hover_data={"weighting": ":.2%"},
215
+ )
216
+ fig.update_traces(hovertemplate="<b>%{label}</b><br>Weight = %{customdata:.3p}")
178
217
  return fig
179
218
 
180
219
 
@@ -185,65 +224,212 @@ class DistributionTableViewSet(AbstractDistributionMixin, ExportPandasAPIViewSet
185
224
  button_config_class = DistributionTableButtonConfig
186
225
 
187
226
  def get_pandas_fields(self, request):
188
- if self.request.GET.get("group_by") != "INDUSTRY":
189
- fields = [
190
- pf.PKField(key="aggregate_field", label=""),
191
- ]
192
- else:
193
- fields = [
194
- pf.PKField(key="id", label="IDS"),
195
- pf.CharField(key="equity", label="Equity"),
196
- ]
197
- for level_rep in self.classification_levels_representation:
198
- fields.append(pf.CharField(key=level_rep, label=level_rep))
199
- fields.extend([pf.FloatField(key="weighting", label="Weight", precision=2, percent=True)])
200
- return pf.PandasFields(fields=tuple(fields))
201
-
202
- def get_date_filter(self):
203
- if date_str := self.request.GET.get("date", None):
204
- val_date = dt.datetime.strptime(date_str, "%Y-%m-%d").date()
205
- elif super().get_queryset().exists():
206
- val_date = AssetPosition.objects.latest("date").date
227
+ fields = [
228
+ pf.PKField(key="id", label="id"),
229
+ pf.FloatField(key="weighting", label="Weight", precision=2, percent=True),
230
+ ]
231
+ for key, label in self.columns_map.items():
232
+ fields.append(pf.CharField(key=key, label=label))
233
+ return pf.PandasFields(fields=fields)
234
+
235
+ def get_aggregates(self, request, df):
236
+ return {"weighting": {"Σ": format_number(df["weighting"].sum())}}
237
+
238
+
239
+ # ##### CHART VIEWS #####
240
+
241
+
242
+ class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
243
+ filterset_class = ContributionChartFilter
244
+ filter_backends = (DjangoFilterBackend,)
245
+ IDENTIFIER = "wbportfolio:portfolio-contributor"
246
+ queryset = AssetPosition.objects.all()
247
+
248
+ title_config_class = ContributorPortfolioChartTitleConfig
249
+ endpoint_config_class = ContributorPortfolioChartEndpointConfig
250
+
251
+ ROW_HEIGHT: int = 20
252
+
253
+ @property
254
+ def min_height(self):
255
+ if hasattr(self, "nb_rows"):
256
+ return self.nb_rows * self.ROW_HEIGHT
257
+ return "300px"
258
+
259
+ @cached_property
260
+ def hedged_currency(self) -> Currency | None:
261
+ if "hedged_currency" in self.request.GET:
262
+ with suppress(Currency.DoesNotExist):
263
+ return Currency.objects.get(pk=self.request.GET["hedged_currency"])
264
+
265
+ @cached_property
266
+ def show_lookthrough(self) -> bool:
267
+ return self.portfolio.is_composition and self.request.GET.get("show_lookthrough", "false").lower() == "true"
268
+
269
+ def get_filterset_class(self, request):
270
+ if self.portfolio.is_composition:
271
+ return CompositionContributionChartFilter
272
+ return ContributionChartFilter
273
+
274
+ def get_plotly(self, queryset):
275
+ fig = go.Figure()
276
+ data = []
277
+ if self.show_lookthrough:
278
+ d1, d2 = get_date_interval_from_request(self.request)
279
+ for _d in pd.date_range(d1, d2):
280
+ for pos in self.portfolio.get_lookthrough_positions(_d.date()):
281
+ data.append(
282
+ [
283
+ pos.date,
284
+ pos.initial_price,
285
+ pos.initial_currency_fx_rate,
286
+ pos.underlying_instrument_id,
287
+ pos.weighting,
288
+ ]
289
+ )
207
290
  else:
208
- val_date = dt.date.today()
209
- return val_date
291
+ data = queryset.annotate_hedged_currency_fx_rate(self.hedged_currency).values_list(
292
+ "date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
293
+ )
294
+ df = Portfolio.get_contribution_df(data).rename(columns={"group_key": "underlying_instrument"})
295
+ if not df.empty:
296
+ df = df[["contribution_total", "contribution_forex", "underlying_instrument"]].sort_values(
297
+ by="contribution_total", ascending=True
298
+ )
299
+
300
+ df["instrument_id"] = df.underlying_instrument.map(
301
+ dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
302
+ )
303
+ df_forex = df[["instrument_id", "contribution_forex"]]
304
+ df_forex = df_forex[df_forex.contribution_forex != 0]
305
+
306
+ contribution_equity = df.contribution_total - df.contribution_forex
307
+
308
+ text_forex = df_forex.contribution_forex.apply(lambda x: f"{x:,.2%}")
309
+ text_equity = contribution_equity.apply(lambda x: f"{x:,.2%}")
310
+ setattr(self, "nb_rows", df.shape[0])
311
+ fig.add_trace(
312
+ go.Bar(
313
+ y=df.instrument_id,
314
+ x=contribution_equity,
315
+ name="Contribution Equity",
316
+ orientation="h",
317
+ marker=dict(
318
+ color="rgba(247,110,91,0.6)",
319
+ line=dict(color="rgb(247,110,91,1.0)", width=2),
320
+ ),
321
+ text=text_equity.values,
322
+ textposition="auto",
323
+ )
324
+ )
325
+ fig.add_trace(
326
+ go.Bar(
327
+ y=df_forex.instrument_id,
328
+ x=df_forex.contribution_forex,
329
+ name="Contribution Forex",
330
+ orientation="h",
331
+ marker=dict(
332
+ color="rgba(58, 71, 80, 0.6)",
333
+ line=dict(color="rgba(58, 71, 80, 1.0)", width=2),
334
+ ),
335
+ text=text_forex.values,
336
+ textposition="outside",
337
+ )
338
+ )
339
+ fig.update_layout(
340
+ barmode="relative",
341
+ xaxis=dict(showgrid=False, showline=False, zeroline=False, tickformat=".2%"),
342
+ yaxis=dict(showgrid=False, showline=False, zeroline=False, tickmode="linear"),
343
+ margin=dict(b=0, r=20, l=20, t=0, pad=20),
344
+ paper_bgcolor="rgba(0,0,0,0)",
345
+ plot_bgcolor="rgba(0,0,0,0)",
346
+ font=dict(family="roboto", size=12, color="black"),
347
+ bargap=0.3,
348
+ )
349
+ # fig = get_horizontal_barplot(df, x_label="contribution_total", y_label="name")
350
+ return fig
351
+
352
+ def parse_figure_dict(self, figure_dict: dict[str, any]) -> dict[str, any]:
353
+ figure_dict = super().parse_figure_dict(figure_dict)
354
+ figure_dict["style"]["minHeight"] = self.min_height
355
+ return figure_dict
356
+
357
+ def get_queryset(self):
358
+ if self.has_portfolio_access:
359
+ return super().get_queryset().filter(portfolio=self.portfolio)
360
+ return AssetPosition.objects.none()
361
+
362
+
363
+ class AssetPositionUnderlyingInstrumentChartViewSet(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
364
+ IDENTIFIER = "wbportfolio:assetpositionchart"
365
+
366
+ queryset = AssetPosition.objects.all()
367
+
368
+ title_config_class = AssetPositionUnderlyingInstrumentChartTitleConfig
369
+ endpoint_config_class = AssetPositionUnderlyingInstrumentChartEndpointConfig
370
+ filterset_class = AssetPositionUnderlyingInstrumentChartFilter
210
371
 
211
372
  def get_queryset(self):
212
- val_date = self.get_date_filter()
213
- queryset = super().get_queryset().filter(date=val_date)
214
- return queryset
215
-
216
- def get_dataframe(self, request, queryset, **kwargs):
217
- group_by = self.request.GET.get("group_by", "COUNTRY")
218
- class_method = AssetPositionGroupBy.get_class_method_group_by(name=group_by)
219
- queryset_without_cash = queryset.exclude(underlying_instrument__is_cash=True)
220
- if group_by not in ["INDUSTRY", "CURRENCY"]:
221
- df = self.dataframe_groupby_with_class_method(qs=queryset_without_cash, class_method=class_method)
222
- elif group_by == "CURRENCY":
223
- df = self.dataframe_groupby_with_class_method(qs=queryset, class_method=class_method)
224
- else: # group_by == "INDUSTRY"
225
- df = self._generate_classification_df(queryset_without_cash)
226
-
227
- if not df.empty:
228
- df.weighting /= df.weighting.sum()
229
- df = df.reset_index().rename(columns={**self.classification_columns_map, "index": "equity"})
230
- df["equity"] = df["equity"].map(
231
- dict(Instrument.objects.filter(id__in=df["equity"]).values_list("id", "name_repr"))
373
+ return AssetPosition.objects.filter(underlying_quote__in=self.instrument.get_descendants(include_self=True))
374
+
375
+ def get_plotly(self, queryset):
376
+ fig = make_subplots(specs=[[{"secondary_y": True}]])
377
+ fig = get_default_timeserie_figure(fig)
378
+ if queryset.exists():
379
+ df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
380
+ df_weight = df_weight.where(pd.notnull(df_weight), 0)
381
+ df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
382
+ min_date = df_weight["date"].min()
383
+ max_date = df_weight["date"].max()
384
+
385
+ df_price = (
386
+ pd.DataFrame(
387
+ self.instrument.prices.filter_only_valid_prices()
388
+ .annotate_base_data()
389
+ .filter(date__gte=min_date, date__lte=max_date)
390
+ .values_list("date", "net_value_usd"),
391
+ columns=["date", "price_fx_usd"],
232
392
  )
233
- for level in self.classification_levels_representation:
234
- tmp = df.groupby(by=level).weighting.sum().astype(float).mul(100).round(1)
235
- df = df.join(tmp, on=level, rsuffix=f"_{level}")
236
- df[level] += " (" + df[f"weighting_{level}"].astype(str) + "%)"
237
- portfolio = Portfolio.objects.get(id=self.request.GET.get("portfolio"))
238
- if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=portfolio):
239
- df[["weighting", "equity"]] = None
240
- df.drop_duplicates(inplace=True)
241
- return df
393
+ .set_index("date")
394
+ .sort_index()
395
+ )
242
396
 
243
- def manipulate_dataframe(self, df):
244
- if not df.empty:
245
- df.sort_values(by="weighting", ascending=False, inplace=True)
246
- if df.weighting.sum() != 1: # normalize
247
- df.weighting /= df.weighting.sum()
248
- df = df.reset_index(names="aggregate_field" if self.request.GET.get("group_by") != "INDUSTRY" else "id")
249
- return df
397
+ fig.add_trace(
398
+ go.Scatter(
399
+ x=df_price.index, y=df_price.price_fx_usd, mode="lines", marker_color="green", name="Price"
400
+ ),
401
+ secondary_y=False,
402
+ )
403
+
404
+ df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
405
+ df_weight = df_weight.where(pd.notnull(df_weight), 0)
406
+ df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
407
+ for portfolio_name, df_tmp in df_weight.groupby("portfolio__name"):
408
+ fig.add_trace(
409
+ go.Scatter(
410
+ x=df_tmp.date,
411
+ y=df_tmp.weighting,
412
+ hovertemplate=get_hovertemplate_timeserie(is_percent=True),
413
+ mode="lines",
414
+ name=f"Allocation: {portfolio_name}",
415
+ ),
416
+ secondary_y=True,
417
+ )
418
+
419
+ # Set x-axis title
420
+ fig.update_xaxes(title_text="Date")
421
+ # Set y-axes titles
422
+ fig.update_yaxes(
423
+ title_text="<b>Price</b>",
424
+ secondary_y=False,
425
+ titlefont=dict(color="green"),
426
+ tickfont=dict(color="green"),
427
+ )
428
+ fig.update_yaxes(
429
+ title_text="<b>Portfolio Allocation (%)</b>",
430
+ secondary_y=True,
431
+ titlefont=dict(color="blue"),
432
+ tickfont=dict(color="blue"),
433
+ )
434
+
435
+ return fig
@@ -1,15 +1,18 @@
1
+ from rest_framework.reverse import reverse
1
2
  from wbcore.contrib.icons import WBIcon
2
3
  from wbcore.metadata.configs import buttons as bt
3
4
  from wbcore.metadata.configs.buttons.view_config import ButtonViewConfig
4
5
  from wbfdm.viewsets.configs.buttons.instruments import InstrumentButtonViewConfig
5
6
 
7
+ from wbportfolio.models import Product
8
+
6
9
 
7
10
  class ProductButtonConfig(ButtonViewConfig):
8
11
  def get_custom_list_instance_buttons(self):
9
12
  return self.get_custom_instance_buttons()
10
13
 
11
14
  def get_custom_instance_buttons(self):
12
- return {
15
+ buttons = [
13
16
  bt.DropDownButton(
14
17
  label="Commission",
15
18
  icon=WBIcon.UNFOLD.icon,
@@ -35,7 +38,34 @@ class ProductButtonConfig(ButtonViewConfig):
35
38
  ),
36
39
  ],
37
40
  ),
38
- }
41
+ ]
42
+ if product_id := self.view.kwargs.get("pk", None):
43
+ product = Product.objects.get(id=product_id)
44
+ report_buttons = []
45
+ for report in product.reports.filter(is_active=True):
46
+ tmp_buttons = []
47
+ if primary_version := report.primary_version:
48
+ tmp_buttons.append(
49
+ bt.HyperlinkButton(
50
+ label="Public Report",
51
+ endpoint=reverse(
52
+ "public_report:report_version", args=[primary_version.lookup], request=self.request
53
+ ),
54
+ ),
55
+ )
56
+ if self.request.user.profile.is_internal or self.request.user.is_superuser:
57
+ tmp_buttons.append(
58
+ bt.WidgetButton(
59
+ label="Widget",
60
+ endpoint=reverse("wbreport:report-detail", args=[report.id], request=self.request),
61
+ ),
62
+ )
63
+ if tmp_buttons:
64
+ report_buttons.append(bt.DropDownButton(label=str(report), buttons=tmp_buttons))
65
+ if report_buttons:
66
+ buttons.append(bt.DropDownButton(label="Reports", buttons=report_buttons))
67
+
68
+ return set(buttons)
39
69
 
40
70
 
41
71
  class ProductCustomerButtonConfig(ButtonViewConfig):
@@ -234,22 +234,9 @@ class CompositionModelPortfolioPandasDisplayConfig(DisplayViewConfig):
234
234
 
235
235
  class DistributionTableDisplayConfig(DisplayViewConfig):
236
236
  def get_list_display(self) -> Optional[dp.ListDisplay]:
237
- if group_by := self.request.GET.get("group_by", ""):
238
- group_by = group_by.title().replace("_", " ")
239
-
240
- if group_by != "Industry":
241
- fields = [
242
- dp.Field(key="aggregate_field", label=group_by),
243
- dp.Field(key="weighting", label="Weight"),
244
- ]
245
- else:
246
- fields = []
247
- for level_rep in self.view.classification_levels_representation:
248
- fields.append(dp.Field(key=level_rep, label=level_rep))
249
- fields.extend(
250
- [
251
- dp.Field(key="equity", label="Equity"),
252
- dp.Field(key="weighting", label="Weight"),
253
- ]
254
- )
255
- return dp.ListDisplay(fields=tuple(fields))
237
+ fields = [
238
+ dp.Field(key="weighting", label="Weighting"),
239
+ ]
240
+ for k, v in reversed(self.view.columns_map.items()):
241
+ fields.append(dp.Field(key=k, label=v))
242
+ return dp.ListDisplay(fields=fields)