wbportfolio 1.54.22__py2.py3-none-any.whl → 1.54.23__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/factories/orders/orders.py +10 -2
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +4 -6
- wbportfolio/models/orders/order_proposals.py +53 -20
- wbportfolio/models/orders/orders.py +4 -1
- wbportfolio/models/portfolio.py +1 -0
- wbportfolio/models/products.py +9 -0
- wbportfolio/pms/trading/handler.py +0 -1
- wbportfolio/serializers/orders/order_proposals.py +2 -18
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +61 -15
- wbportfolio/tests/models/test_products.py +11 -0
- 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 +338 -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/orders/configs/buttons/order_proposals.py +12 -2
- wbportfolio/viewsets/orders/order_proposals.py +2 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/RECORD +30 -30
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.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,43 @@ 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
|
+
return AssetPositionGroupBy(self.request.GET.get("group_by", "classification"))
|
|
75
|
+
|
|
76
|
+
@cached_property
|
|
77
|
+
def val_date(self) -> dt.date:
|
|
78
|
+
if validity_date_repr := self.request.GET.get("date"):
|
|
79
|
+
val_date = dt.datetime.strptime(validity_date_repr, "%Y-%m-%d")
|
|
80
|
+
else:
|
|
81
|
+
val_date = dt.date.today()
|
|
82
|
+
return val_date
|
|
49
83
|
|
|
50
84
|
@cached_property
|
|
51
85
|
def classification_group(self):
|
|
@@ -55,71 +89,95 @@ class AbstractDistributionMixin:
|
|
|
55
89
|
return ClassificationGroup.objects.get(is_primary=True)
|
|
56
90
|
|
|
57
91
|
@cached_property
|
|
58
|
-
def
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
@cached_property
|
|
62
|
-
def classification_levels_representation(self):
|
|
63
|
-
return self.classification_group.get_levels_representation()
|
|
92
|
+
def classification_height(self) -> int:
|
|
93
|
+
return int(self.request.GET.get("group_by_classification_height", "0"))
|
|
64
94
|
|
|
65
95
|
@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
|
-
)
|
|
96
|
+
def columns_map(self) -> dict:
|
|
97
|
+
if self.group_by == AssetPositionGroupBy.INDUSTRY:
|
|
98
|
+
columns = {}
|
|
99
|
+
level_representations = self.classification_group.get_levels_representation()
|
|
100
|
+
for key, label in zip(
|
|
101
|
+
reversed(self.classification_group.get_fields_names(sep="_")), reversed(level_representations[1:])
|
|
102
|
+
):
|
|
103
|
+
columns[key] = label
|
|
104
|
+
columns["label"] = "Classification"
|
|
105
|
+
columns["equity"] = "Instrument"
|
|
106
|
+
else:
|
|
107
|
+
columns = {"label": "Label"}
|
|
108
|
+
return columns
|
|
101
109
|
|
|
102
110
|
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)
|
|
111
|
+
profile = self.request.user.profile
|
|
112
|
+
if PortfolioRole.is_analyst(profile, portfolio=self.portfolio) or profile.is_internal:
|
|
113
|
+
return AssetPosition.objects.filter(portfolio=self.portfolio)
|
|
109
114
|
return AssetPosition.objects.none()
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
def get_dataframe(self, request, queryset, **kwargs) -> pd.DataFrame:
|
|
117
|
+
instruments = defaultdict(Decimal)
|
|
118
|
+
for asset in self.portfolio.get_positions(self.val_date):
|
|
119
|
+
if self.group_by != AssetPositionGroupBy.INDUSTRY:
|
|
120
|
+
group_field = getattr(asset.underlying_instrument, self.group_by.value)
|
|
121
|
+
else:
|
|
122
|
+
group_field = asset.underlying_instrument.get_root()
|
|
123
|
+
group_field = getattr(group_field, "id", group_field)
|
|
124
|
+
instruments[group_field] += asset.weighting
|
|
125
|
+
df = pd.DataFrame.from_dict(instruments, orient="index", columns=["weighting"])
|
|
126
|
+
if self.group_by == AssetPositionGroupBy.INDUSTRY:
|
|
127
|
+
classifications = InstrumentClassificationThroughModel.objects.filter(
|
|
128
|
+
instrument__in=instruments.keys(), classification__group=self.classification_group
|
|
129
|
+
)
|
|
130
|
+
field_names = {
|
|
131
|
+
field_name.replace("__", "_"): F(f"classification__{field_name}__name")
|
|
132
|
+
for field_name in self.classification_group.get_fields_names()
|
|
133
|
+
}
|
|
134
|
+
classifications = (
|
|
135
|
+
classifications.annotate(**field_names)
|
|
136
|
+
.select_related(
|
|
137
|
+
*[f"classification__{field_name}" for field_name in self.classification_group.get_fields_names()]
|
|
138
|
+
)
|
|
139
|
+
.prefetch_related(
|
|
140
|
+
"tags",
|
|
141
|
+
Prefetch("instrument", queryset=Instrument.objects.filter(classifications_through__isnull=False)),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
df_classification = (
|
|
145
|
+
pd.DataFrame(
|
|
146
|
+
classifications.values_list("instrument", "classification", *field_names.keys()),
|
|
147
|
+
columns=["id", "classification", *field_names.keys()],
|
|
148
|
+
)
|
|
149
|
+
.groupby("id")
|
|
150
|
+
.first()
|
|
151
|
+
)
|
|
152
|
+
df = pd.concat([df, df_classification], axis=1)
|
|
153
|
+
if df.weighting.sum(): # normalize
|
|
154
|
+
df.weighting /= df.weighting.sum()
|
|
155
|
+
return df.reset_index(names="id")
|
|
116
156
|
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
def manipulate_dataframe(self, df):
|
|
158
|
+
if self.group_by == AssetPositionGroupBy.INDUSTRY:
|
|
159
|
+
if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=self.portfolio):
|
|
160
|
+
df["equity"] = ""
|
|
161
|
+
else:
|
|
162
|
+
df["equity"] = df["id"].map(
|
|
163
|
+
dict(Instrument.objects.filter(id__in=df["id"]).values_list("id", "computed_str"))
|
|
164
|
+
)
|
|
165
|
+
df["label"] = df["classification"].map(
|
|
166
|
+
dict(Classification.objects.filter(id__in=df["classification"].dropna()).values_list("id", "name"))
|
|
167
|
+
)
|
|
168
|
+
elif self.group_by == AssetPositionGroupBy.CASH:
|
|
169
|
+
df.loc[df["id"] == True, "label"] = "Cash"
|
|
170
|
+
df.loc[df["id"] == False, "label"] = "Non-Cash"
|
|
171
|
+
elif self.group_by == AssetPositionGroupBy.COUNTRY:
|
|
172
|
+
df["label"] = df["id"].map(dict(Geography.objects.filter(id__in=df["id"]).values_list("id", "name")))
|
|
173
|
+
elif self.group_by == AssetPositionGroupBy.CURRENCY:
|
|
174
|
+
currencies = dict(map(lambda o: (o.id, str(o)), Currency.objects.filter(id__in=df["id"])))
|
|
175
|
+
df["label"] = df["id"].map(currencies)
|
|
176
|
+
elif self.group_by == AssetPositionGroupBy.INSTRUMENT_TYPE:
|
|
177
|
+
df["label"] = df["id"].map(
|
|
178
|
+
dict(InstrumentType.objects.filter(id__in=df["id"]).values_list("id", "short_name"))
|
|
122
179
|
)
|
|
180
|
+
df.sort_values(by="weighting", ascending=False, inplace=True)
|
|
123
181
|
return df
|
|
124
182
|
|
|
125
183
|
|
|
@@ -142,39 +200,17 @@ class DistributionChartViewSet(AbstractDistributionMixin, viewsets.ChartViewSet)
|
|
|
142
200
|
|
|
143
201
|
def get_plotly(self, queryset):
|
|
144
202
|
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}")
|
|
203
|
+
df = self.manipulate_dataframe(self.get_dataframe(self.request, queryset))
|
|
204
|
+
df = df.dropna(how="any")
|
|
205
|
+
if not df.empty:
|
|
206
|
+
levels = list(self.columns_map.keys())
|
|
207
|
+
fig = px.sunburst(
|
|
208
|
+
df,
|
|
209
|
+
path=levels,
|
|
210
|
+
values="weighting",
|
|
211
|
+
hover_data={"weighting": ":.2%"},
|
|
212
|
+
)
|
|
213
|
+
fig.update_traces(hovertemplate="<b>%{label}</b><br>Weight = %{customdata:.3p}")
|
|
178
214
|
return fig
|
|
179
215
|
|
|
180
216
|
|
|
@@ -185,65 +221,212 @@ class DistributionTableViewSet(AbstractDistributionMixin, ExportPandasAPIViewSet
|
|
|
185
221
|
button_config_class = DistributionTableButtonConfig
|
|
186
222
|
|
|
187
223
|
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
|
-
|
|
224
|
+
fields = [
|
|
225
|
+
pf.PKField(key="id", label="id"),
|
|
226
|
+
pf.FloatField(key="weighting", label="Weight", precision=2, percent=True),
|
|
227
|
+
]
|
|
228
|
+
for key, label in self.columns_map.items():
|
|
229
|
+
fields.append(pf.CharField(key=key, label=label))
|
|
230
|
+
return pf.PandasFields(fields=fields)
|
|
231
|
+
|
|
232
|
+
def get_aggregates(self, request, df):
|
|
233
|
+
return {"weighting": {"Σ": format_number(df["weighting"].sum())}}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ##### CHART VIEWS #####
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
|
|
240
|
+
filterset_class = ContributionChartFilter
|
|
241
|
+
filter_backends = (DjangoFilterBackend,)
|
|
242
|
+
IDENTIFIER = "wbportfolio:portfolio-contributor"
|
|
243
|
+
queryset = AssetPosition.objects.all()
|
|
244
|
+
|
|
245
|
+
title_config_class = ContributorPortfolioChartTitleConfig
|
|
246
|
+
endpoint_config_class = ContributorPortfolioChartEndpointConfig
|
|
247
|
+
|
|
248
|
+
ROW_HEIGHT: int = 20
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def min_height(self):
|
|
252
|
+
if hasattr(self, "nb_rows"):
|
|
253
|
+
return self.nb_rows * self.ROW_HEIGHT
|
|
254
|
+
return "300px"
|
|
255
|
+
|
|
256
|
+
@cached_property
|
|
257
|
+
def hedged_currency(self) -> Currency | None:
|
|
258
|
+
if "hedged_currency" in self.request.GET:
|
|
259
|
+
with suppress(Currency.DoesNotExist):
|
|
260
|
+
return Currency.objects.get(pk=self.request.GET["hedged_currency"])
|
|
261
|
+
|
|
262
|
+
@cached_property
|
|
263
|
+
def show_lookthrough(self) -> bool:
|
|
264
|
+
return self.portfolio.is_composition and self.request.GET.get("show_lookthrough", "false").lower() == "true"
|
|
265
|
+
|
|
266
|
+
def get_filterset_class(self, request):
|
|
267
|
+
if self.portfolio.is_composition:
|
|
268
|
+
return CompositionContributionChartFilter
|
|
269
|
+
return ContributionChartFilter
|
|
270
|
+
|
|
271
|
+
def get_plotly(self, queryset):
|
|
272
|
+
fig = go.Figure()
|
|
273
|
+
data = []
|
|
274
|
+
if self.show_lookthrough:
|
|
275
|
+
d1, d2 = get_date_interval_from_request(self.request)
|
|
276
|
+
for _d in pd.date_range(d1, d2):
|
|
277
|
+
for pos in self.portfolio.get_lookthrough_positions(_d.date()):
|
|
278
|
+
data.append(
|
|
279
|
+
[
|
|
280
|
+
pos.date,
|
|
281
|
+
pos.initial_price,
|
|
282
|
+
pos.initial_currency_fx_rate,
|
|
283
|
+
pos.underlying_instrument_id,
|
|
284
|
+
pos.weighting,
|
|
285
|
+
]
|
|
286
|
+
)
|
|
207
287
|
else:
|
|
208
|
-
|
|
209
|
-
|
|
288
|
+
data = queryset.annotate_hedged_currency_fx_rate(self.hedged_currency).values_list(
|
|
289
|
+
"date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
|
|
290
|
+
)
|
|
291
|
+
df = Portfolio.get_contribution_df(data).rename(columns={"group_key": "underlying_instrument"})
|
|
292
|
+
if not df.empty:
|
|
293
|
+
df = df[["contribution_total", "contribution_forex", "underlying_instrument"]].sort_values(
|
|
294
|
+
by="contribution_total", ascending=True
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
df["instrument_id"] = df.underlying_instrument.map(
|
|
298
|
+
dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
|
|
299
|
+
)
|
|
300
|
+
df_forex = df[["instrument_id", "contribution_forex"]]
|
|
301
|
+
df_forex = df_forex[df_forex.contribution_forex != 0]
|
|
302
|
+
|
|
303
|
+
contribution_equity = df.contribution_total - df.contribution_forex
|
|
304
|
+
|
|
305
|
+
text_forex = df_forex.contribution_forex.apply(lambda x: f"{x:,.2%}")
|
|
306
|
+
text_equity = contribution_equity.apply(lambda x: f"{x:,.2%}")
|
|
307
|
+
setattr(self, "nb_rows", df.shape[0])
|
|
308
|
+
fig.add_trace(
|
|
309
|
+
go.Bar(
|
|
310
|
+
y=df.instrument_id,
|
|
311
|
+
x=contribution_equity,
|
|
312
|
+
name="Contribution Equity",
|
|
313
|
+
orientation="h",
|
|
314
|
+
marker=dict(
|
|
315
|
+
color="rgba(247,110,91,0.6)",
|
|
316
|
+
line=dict(color="rgb(247,110,91,1.0)", width=2),
|
|
317
|
+
),
|
|
318
|
+
text=text_equity.values,
|
|
319
|
+
textposition="auto",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
fig.add_trace(
|
|
323
|
+
go.Bar(
|
|
324
|
+
y=df_forex.instrument_id,
|
|
325
|
+
x=df_forex.contribution_forex,
|
|
326
|
+
name="Contribution Forex",
|
|
327
|
+
orientation="h",
|
|
328
|
+
marker=dict(
|
|
329
|
+
color="rgba(58, 71, 80, 0.6)",
|
|
330
|
+
line=dict(color="rgba(58, 71, 80, 1.0)", width=2),
|
|
331
|
+
),
|
|
332
|
+
text=text_forex.values,
|
|
333
|
+
textposition="outside",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
fig.update_layout(
|
|
337
|
+
barmode="relative",
|
|
338
|
+
xaxis=dict(showgrid=False, showline=False, zeroline=False, tickformat=".2%"),
|
|
339
|
+
yaxis=dict(showgrid=False, showline=False, zeroline=False, tickmode="linear"),
|
|
340
|
+
margin=dict(b=0, r=20, l=20, t=0, pad=20),
|
|
341
|
+
paper_bgcolor="rgba(0,0,0,0)",
|
|
342
|
+
plot_bgcolor="rgba(0,0,0,0)",
|
|
343
|
+
font=dict(family="roboto", size=12, color="black"),
|
|
344
|
+
bargap=0.3,
|
|
345
|
+
)
|
|
346
|
+
# fig = get_horizontal_barplot(df, x_label="contribution_total", y_label="name")
|
|
347
|
+
return fig
|
|
348
|
+
|
|
349
|
+
def parse_figure_dict(self, figure_dict: dict[str, any]) -> dict[str, any]:
|
|
350
|
+
figure_dict = super().parse_figure_dict(figure_dict)
|
|
351
|
+
figure_dict["style"]["minHeight"] = self.min_height
|
|
352
|
+
return figure_dict
|
|
353
|
+
|
|
354
|
+
def get_queryset(self):
|
|
355
|
+
if self.has_portfolio_access:
|
|
356
|
+
return super().get_queryset().filter(portfolio=self.portfolio)
|
|
357
|
+
return AssetPosition.objects.none()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class AssetPositionUnderlyingInstrumentChartViewSet(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
|
|
361
|
+
IDENTIFIER = "wbportfolio:assetpositionchart"
|
|
362
|
+
|
|
363
|
+
queryset = AssetPosition.objects.all()
|
|
364
|
+
|
|
365
|
+
title_config_class = AssetPositionUnderlyingInstrumentChartTitleConfig
|
|
366
|
+
endpoint_config_class = AssetPositionUnderlyingInstrumentChartEndpointConfig
|
|
367
|
+
filterset_class = AssetPositionUnderlyingInstrumentChartFilter
|
|
210
368
|
|
|
211
369
|
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"))
|
|
370
|
+
return AssetPosition.objects.filter(underlying_quote__in=self.instrument.get_descendants(include_self=True))
|
|
371
|
+
|
|
372
|
+
def get_plotly(self, queryset):
|
|
373
|
+
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
|
374
|
+
fig = get_default_timeserie_figure(fig)
|
|
375
|
+
if queryset.exists():
|
|
376
|
+
df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
|
|
377
|
+
df_weight = df_weight.where(pd.notnull(df_weight), 0)
|
|
378
|
+
df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
|
|
379
|
+
min_date = df_weight["date"].min()
|
|
380
|
+
max_date = df_weight["date"].max()
|
|
381
|
+
|
|
382
|
+
df_price = (
|
|
383
|
+
pd.DataFrame(
|
|
384
|
+
self.instrument.prices.filter_only_valid_prices()
|
|
385
|
+
.annotate_base_data()
|
|
386
|
+
.filter(date__gte=min_date, date__lte=max_date)
|
|
387
|
+
.values_list("date", "net_value_usd"),
|
|
388
|
+
columns=["date", "price_fx_usd"],
|
|
232
389
|
)
|
|
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
|
|
390
|
+
.set_index("date")
|
|
391
|
+
.sort_index()
|
|
392
|
+
)
|
|
242
393
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
394
|
+
fig.add_trace(
|
|
395
|
+
go.Scatter(
|
|
396
|
+
x=df_price.index, y=df_price.price_fx_usd, mode="lines", marker_color="green", name="Price"
|
|
397
|
+
),
|
|
398
|
+
secondary_y=False,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
|
|
402
|
+
df_weight = df_weight.where(pd.notnull(df_weight), 0)
|
|
403
|
+
df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
|
|
404
|
+
for portfolio_name, df_tmp in df_weight.groupby("portfolio__name"):
|
|
405
|
+
fig.add_trace(
|
|
406
|
+
go.Scatter(
|
|
407
|
+
x=df_tmp.date,
|
|
408
|
+
y=df_tmp.weighting,
|
|
409
|
+
hovertemplate=get_hovertemplate_timeserie(is_percent=True),
|
|
410
|
+
mode="lines",
|
|
411
|
+
name=f"Allocation: {portfolio_name}",
|
|
412
|
+
),
|
|
413
|
+
secondary_y=True,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Set x-axis title
|
|
417
|
+
fig.update_xaxes(title_text="Date")
|
|
418
|
+
# Set y-axes titles
|
|
419
|
+
fig.update_yaxes(
|
|
420
|
+
title_text="<b>Price</b>",
|
|
421
|
+
secondary_y=False,
|
|
422
|
+
titlefont=dict(color="green"),
|
|
423
|
+
tickfont=dict(color="green"),
|
|
424
|
+
)
|
|
425
|
+
fig.update_yaxes(
|
|
426
|
+
title_text="<b>Portfolio Allocation (%)</b>",
|
|
427
|
+
secondary_y=True,
|
|
428
|
+
titlefont=dict(color="blue"),
|
|
429
|
+
tickfont=dict(color="blue"),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
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)
|
|
@@ -124,7 +124,7 @@ class ProductDisplayConfig(DisplayViewConfig):
|
|
|
124
124
|
return create_simple_display(
|
|
125
125
|
[
|
|
126
126
|
[repeat_field(2, "name"), repeat_field(2, "name_repr")],
|
|
127
|
-
["inception_date", "
|
|
127
|
+
["inception_date", "delisted_date", "isin", "id_repr"],
|
|
128
128
|
["share_price", "issue_price", "initial_high_water_mark", "currency"],
|
|
129
129
|
[repeat_field(2, "bank"), repeat_field(2, "parent")],
|
|
130
130
|
[repeat_field(4, "tags")],
|