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.
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +10 -2
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +74 -31
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +549 -167
- wbportfolio/models/orders/orders.py +24 -11
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +77 -41
- wbportfolio/models/products.py +9 -0
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -1
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +25 -21
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +341 -155
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +47 -7
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {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
|
|
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.
|
|
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
|
|
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
|
|
59
|
-
return
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
fields =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
fields =
|
|
242
|
-
|
|
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)
|