wbportfolio 1.52.0__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.

Potentially problematic release.


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

Files changed (273) hide show
  1. wbportfolio/admin/__init__.py +3 -1
  2. wbportfolio/admin/indexes.py +1 -1
  3. wbportfolio/admin/orders/__init__.py +2 -0
  4. wbportfolio/admin/orders/order_proposals.py +16 -0
  5. wbportfolio/admin/orders/orders.py +32 -0
  6. wbportfolio/admin/portfolio.py +11 -5
  7. wbportfolio/admin/product_groups.py +1 -1
  8. wbportfolio/admin/products.py +2 -1
  9. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  10. wbportfolio/admin/transactions/__init__.py +0 -2
  11. wbportfolio/admin/transactions/dividends.py +40 -4
  12. wbportfolio/admin/transactions/fees.py +24 -14
  13. wbportfolio/admin/transactions/trades.py +34 -27
  14. wbportfolio/analysis/claims.py +5 -6
  15. wbportfolio/api_clients/ubs.py +162 -0
  16. wbportfolio/constants.py +1 -0
  17. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  18. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  19. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  20. wbportfolio/contrib/company_portfolio/models.py +69 -39
  21. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  22. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  23. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  24. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  25. wbportfolio/defaults/fees/default.py +7 -15
  26. wbportfolio/factories/__init__.py +2 -2
  27. wbportfolio/factories/assets.py +1 -1
  28. wbportfolio/factories/dividends.py +8 -3
  29. wbportfolio/factories/fees.py +8 -4
  30. wbportfolio/factories/orders/__init__.py +2 -0
  31. wbportfolio/factories/orders/order_proposals.py +21 -0
  32. wbportfolio/factories/orders/orders.py +34 -0
  33. wbportfolio/factories/portfolios.py +2 -1
  34. wbportfolio/factories/product_groups.py +3 -3
  35. wbportfolio/factories/products.py +3 -3
  36. wbportfolio/factories/rebalancing.py +1 -1
  37. wbportfolio/factories/trades.py +12 -16
  38. wbportfolio/filters/assets.py +18 -4
  39. wbportfolio/filters/orders/__init__.py +2 -0
  40. wbportfolio/filters/orders/order_proposals.py +55 -0
  41. wbportfolio/filters/orders/orders.py +11 -0
  42. wbportfolio/filters/portfolios.py +38 -1
  43. wbportfolio/filters/positions.py +0 -1
  44. wbportfolio/filters/transactions/__init__.py +1 -2
  45. wbportfolio/filters/transactions/fees.py +5 -12
  46. wbportfolio/filters/transactions/trades.py +16 -8
  47. wbportfolio/filters/transactions/utils.py +42 -0
  48. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  49. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  50. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  51. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  52. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  53. wbportfolio/import_export/backends/utils.py +0 -17
  54. wbportfolio/import_export/handlers/asset_position.py +22 -10
  55. wbportfolio/import_export/handlers/dividend.py +8 -8
  56. wbportfolio/import_export/handlers/fees.py +13 -23
  57. wbportfolio/import_export/handlers/orders.py +71 -0
  58. wbportfolio/import_export/handlers/trade.py +53 -77
  59. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  60. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  61. wbportfolio/import_export/parsers/jpmorgan/fees.py +4 -4
  62. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  63. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  64. wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
  65. wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
  66. wbportfolio/import_export/parsers/leonteq/trade.py +2 -6
  67. wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
  68. wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
  69. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  70. wbportfolio/import_export/parsers/natixis/fees.py +7 -9
  71. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  72. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
  73. wbportfolio/import_export/parsers/sg_lux/equity.py +10 -10
  74. wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
  75. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
  76. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  77. wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
  78. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  79. wbportfolio/import_export/parsers/societe_generale/strategy.py +5 -5
  80. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  81. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  82. wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
  83. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  84. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  85. wbportfolio/import_export/parsers/ubs/equity.py +3 -2
  86. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  87. wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
  88. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
  89. wbportfolio/import_export/parsers/vontobel/management_fees.py +12 -20
  90. wbportfolio/import_export/parsers/vontobel/performance_fees.py +5 -8
  91. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  92. wbportfolio/import_export/resources/trades.py +3 -3
  93. wbportfolio/import_export/utils.py +3 -1
  94. wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
  95. wbportfolio/metric/backends/base.py +2 -2
  96. wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
  97. wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
  98. wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
  99. wbportfolio/migrations/0079_alter_trade_drift_factor.py +19 -0
  100. wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
  101. wbportfolio/migrations/0081_alter_trade_drift_factor.py +19 -0
  102. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  103. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  104. wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
  105. wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
  106. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  107. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  108. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  109. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  110. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  111. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  112. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  113. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  114. wbportfolio/models/__init__.py +2 -0
  115. wbportfolio/models/adjustments.py +1 -1
  116. wbportfolio/models/asset.py +28 -170
  117. wbportfolio/models/builder.py +323 -0
  118. wbportfolio/models/custodians.py +3 -3
  119. wbportfolio/models/exceptions.py +1 -1
  120. wbportfolio/models/graphs/portfolio.py +1 -1
  121. wbportfolio/models/graphs/utils.py +11 -11
  122. wbportfolio/models/mixins/instruments.py +7 -0
  123. wbportfolio/models/mixins/liquidity_stress_test.py +4 -4
  124. wbportfolio/models/orders/__init__.py +2 -0
  125. wbportfolio/models/orders/order_proposals.py +1414 -0
  126. wbportfolio/models/orders/orders.py +410 -0
  127. wbportfolio/models/portfolio.py +311 -289
  128. wbportfolio/models/portfolio_relationship.py +6 -0
  129. wbportfolio/models/products.py +12 -0
  130. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +40 -27
  131. wbportfolio/models/roles.py +4 -10
  132. wbportfolio/models/transactions/__init__.py +0 -4
  133. wbportfolio/models/transactions/claim.py +7 -6
  134. wbportfolio/models/transactions/dividends.py +42 -5
  135. wbportfolio/models/transactions/fees.py +55 -22
  136. wbportfolio/models/transactions/trades.py +121 -442
  137. wbportfolio/models/transactions/transactions.py +78 -158
  138. wbportfolio/models/utils.py +100 -1
  139. wbportfolio/order_routing/__init__.py +35 -0
  140. wbportfolio/order_routing/adapters/__init__.py +65 -0
  141. wbportfolio/order_routing/adapters/ubs.py +195 -0
  142. wbportfolio/order_routing/router.py +33 -0
  143. wbportfolio/order_routing/tests/__init__.py +0 -0
  144. wbportfolio/order_routing/tests/test_router.py +110 -0
  145. wbportfolio/permissions.py +7 -0
  146. wbportfolio/pms/analytics/portfolio.py +17 -9
  147. wbportfolio/pms/analytics/utils.py +9 -0
  148. wbportfolio/pms/trading/__init__.py +0 -1
  149. wbportfolio/pms/trading/optimizer.py +61 -0
  150. wbportfolio/pms/typing.py +198 -63
  151. wbportfolio/rebalancing/base.py +12 -1
  152. wbportfolio/rebalancing/decorators.py +1 -1
  153. wbportfolio/rebalancing/models/composite.py +4 -8
  154. wbportfolio/rebalancing/models/equally_weighted.py +13 -11
  155. wbportfolio/rebalancing/models/market_capitalization_weighted.py +21 -14
  156. wbportfolio/rebalancing/models/model_portfolio.py +14 -18
  157. wbportfolio/risk_management/backends/__init__.py +1 -0
  158. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  159. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  160. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  161. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  162. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  163. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  164. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  165. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  166. wbportfolio/serializers/__init__.py +1 -0
  167. wbportfolio/serializers/orders/__init__.py +2 -0
  168. wbportfolio/serializers/orders/order_proposals.py +115 -0
  169. wbportfolio/serializers/orders/orders.py +283 -0
  170. wbportfolio/serializers/portfolios.py +7 -7
  171. wbportfolio/serializers/positions.py +2 -2
  172. wbportfolio/serializers/rebalancing.py +1 -1
  173. wbportfolio/serializers/signals.py +9 -12
  174. wbportfolio/serializers/transactions/__init__.py +1 -10
  175. wbportfolio/serializers/transactions/claim.py +2 -2
  176. wbportfolio/serializers/transactions/dividends.py +37 -9
  177. wbportfolio/serializers/transactions/fees.py +39 -10
  178. wbportfolio/serializers/transactions/trades.py +55 -157
  179. wbportfolio/tasks.py +43 -5
  180. wbportfolio/tests/analysis/__init__.py +0 -0
  181. wbportfolio/tests/analysis/test_claims.py +85 -0
  182. wbportfolio/tests/conftest.py +12 -12
  183. wbportfolio/tests/models/orders/__init__.py +0 -0
  184. wbportfolio/tests/models/orders/test_order_proposals.py +1046 -0
  185. wbportfolio/tests/models/test_assets.py +7 -3
  186. wbportfolio/tests/models/test_imports.py +9 -13
  187. wbportfolio/tests/models/test_portfolios.py +102 -95
  188. wbportfolio/tests/models/test_products.py +11 -0
  189. wbportfolio/tests/models/test_splits.py +1 -6
  190. wbportfolio/tests/models/test_utils.py +140 -0
  191. wbportfolio/tests/models/transactions/test_fees.py +7 -13
  192. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  193. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  194. wbportfolio/tests/pms/test_analytics.py +22 -3
  195. wbportfolio/tests/rebalancing/test_models.py +51 -57
  196. wbportfolio/tests/signals.py +10 -20
  197. wbportfolio/tests/tests.py +3 -1
  198. wbportfolio/tests/viewsets/test_products.py +1 -0
  199. wbportfolio/urls.py +10 -13
  200. wbportfolio/viewsets/__init__.py +9 -4
  201. wbportfolio/viewsets/assets.py +3 -204
  202. wbportfolio/viewsets/charts/__init__.py +6 -1
  203. wbportfolio/viewsets/charts/assets.py +344 -154
  204. wbportfolio/viewsets/configs/buttons/__init__.py +2 -2
  205. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  206. wbportfolio/viewsets/configs/buttons/mixins.py +4 -4
  207. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  208. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  209. wbportfolio/viewsets/configs/display/__init__.py +2 -5
  210. wbportfolio/viewsets/configs/display/assets.py +6 -19
  211. wbportfolio/viewsets/configs/display/fees.py +3 -3
  212. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  213. wbportfolio/viewsets/configs/display/products.py +1 -1
  214. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  215. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  216. wbportfolio/viewsets/configs/display/trades.py +1 -189
  217. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -7
  218. wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
  219. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  220. wbportfolio/viewsets/configs/menu/__init__.py +1 -1
  221. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  222. wbportfolio/viewsets/configs/titles/__init__.py +2 -3
  223. wbportfolio/viewsets/configs/titles/fees.py +4 -8
  224. wbportfolio/viewsets/esg.py +3 -5
  225. wbportfolio/viewsets/mixins.py +5 -1
  226. wbportfolio/viewsets/orders/__init__.py +6 -0
  227. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  228. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  229. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +188 -0
  230. wbportfolio/viewsets/orders/configs/buttons/orders.py +113 -0
  231. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  232. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +157 -0
  233. wbportfolio/viewsets/orders/configs/displays/orders.py +232 -0
  234. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  235. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  236. wbportfolio/viewsets/orders/configs/endpoints/orders.py +28 -0
  237. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  238. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  239. wbportfolio/viewsets/orders/order_proposals.py +252 -0
  240. wbportfolio/viewsets/orders/orders.py +277 -0
  241. wbportfolio/viewsets/portfolios.py +36 -12
  242. wbportfolio/viewsets/positions.py +3 -2
  243. wbportfolio/viewsets/products.py +6 -6
  244. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  245. wbportfolio/viewsets/transactions/__init__.py +3 -14
  246. wbportfolio/viewsets/transactions/fees.py +22 -22
  247. wbportfolio/viewsets/transactions/trades.py +1 -180
  248. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +3 -1
  249. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +252 -203
  250. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  251. wbportfolio/admin/transactions/transactions.py +0 -38
  252. wbportfolio/factories/transactions.py +0 -22
  253. wbportfolio/fdm/tasks.py +0 -13
  254. wbportfolio/filters/transactions/transactions.py +0 -99
  255. wbportfolio/models/transactions/expiry.py +0 -7
  256. wbportfolio/models/transactions/trade_proposals.py +0 -704
  257. wbportfolio/pms/trading/handler.py +0 -161
  258. wbportfolio/serializers/transactions/expiry.py +0 -18
  259. wbportfolio/serializers/transactions/trade_proposals.py +0 -76
  260. wbportfolio/serializers/transactions/transactions.py +0 -85
  261. wbportfolio/tests/models/transactions/test_trade_proposals.py +0 -410
  262. wbportfolio/viewsets/configs/buttons/trade_proposals.py +0 -66
  263. wbportfolio/viewsets/configs/display/trade_proposals.py +0 -100
  264. wbportfolio/viewsets/configs/display/transactions.py +0 -55
  265. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  266. wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
  267. wbportfolio/viewsets/configs/menu/transactions.py +0 -9
  268. wbportfolio/viewsets/configs/titles/transactions.py +0 -9
  269. wbportfolio/viewsets/signals.py +0 -43
  270. wbportfolio/viewsets/transactions/trade_proposals.py +0 -139
  271. wbportfolio/viewsets/transactions/transactions.py +0 -122
  272. /wbportfolio/{fdm → api_clients}/__init__.py +0 -0
  273. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,24 +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,
18
30
  InstrumentType,
19
31
  )
20
32
 
21
- from wbportfolio.filters.assets import DistributionFilter
33
+ from wbportfolio.filters.assets import (
34
+ AssetPositionUnderlyingInstrumentChartFilter,
35
+ CompositionContributionChartFilter,
36
+ ContributionChartFilter,
37
+ DistributionFilter,
38
+ )
22
39
  from wbportfolio.models import (
23
40
  AssetPosition,
24
41
  AssetPositionGroupBy,
@@ -32,20 +49,40 @@ from ..configs.buttons.assets import (
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,99 @@ 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=InstrumentType.EQUITY).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="_")),
105
+ reversed(level_representations[1:]),
106
+ strict=False,
107
+ ):
108
+ columns[key] = label
109
+ columns["label"] = "Classification"
110
+ columns["equity"] = "Instrument"
111
+ else:
112
+ columns = {"label": "Label"}
113
+ return columns
101
114
 
102
115
  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)
116
+ profile = self.request.user.profile
117
+ if PortfolioRole.is_analyst(profile, portfolio=self.portfolio) or profile.is_internal:
118
+ return AssetPosition.objects.filter(portfolio=self.portfolio)
109
119
  return AssetPosition.objects.none()
110
120
 
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)
121
+ def get_dataframe(self, request, queryset, **kwargs) -> pd.DataFrame:
122
+ instruments = defaultdict(Decimal)
123
+ for asset in self.portfolio.get_positions(self.val_date):
124
+ if self.group_by != AssetPositionGroupBy.INDUSTRY:
125
+ group_field = getattr(asset.underlying_instrument, self.group_by.value)
126
+ else:
127
+ group_field = asset.underlying_instrument.get_root()
128
+ group_field = getattr(group_field, "id", group_field)
129
+ instruments[group_field] += asset.weighting
130
+ df = pd.DataFrame.from_dict(instruments, orient="index", columns=["weighting"])
131
+ if self.group_by == AssetPositionGroupBy.INDUSTRY:
132
+ classifications = InstrumentClassificationThroughModel.objects.filter(
133
+ instrument__in=instruments.keys(), classification__group=self.classification_group
134
+ )
135
+ field_names = {
136
+ field_name.replace("__", "_"): F(f"classification__{field_name}__name")
137
+ for field_name in self.classification_group.get_fields_names()
138
+ }
139
+ classifications = (
140
+ classifications.annotate(**field_names)
141
+ .select_related(
142
+ *[f"classification__{field_name}" for field_name in self.classification_group.get_fields_names()]
143
+ )
144
+ .prefetch_related(
145
+ "tags",
146
+ Prefetch("instrument", queryset=Instrument.objects.filter(classifications_through__isnull=False)),
147
+ )
148
+ )
149
+ df_classification = (
150
+ pd.DataFrame(
151
+ classifications.values_list("instrument", "classification", *field_names.keys()),
152
+ columns=["id", "classification", *field_names.keys()],
153
+ )
154
+ .groupby("id")
155
+ .first()
156
+ )
157
+ df = pd.concat([df, df_classification], axis=1)
158
+ if df.weighting.sum(): # normalize
159
+ df.weighting /= df.weighting.sum()
160
+ return df.reset_index(names="id")
116
161
 
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"))
162
+ def manipulate_dataframe(self, df):
163
+ df["id"] = df["id"].fillna(-1)
164
+ if self.group_by == AssetPositionGroupBy.INDUSTRY:
165
+ if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=self.portfolio):
166
+ df["equity"] = ""
167
+ else:
168
+ df["equity"] = df["id"].map(
169
+ dict(Instrument.objects.filter(id__in=df["id"]).values_list("id", "computed_str"))
170
+ )
171
+ df["label"] = df["classification"].map(
172
+ dict(Classification.objects.filter(id__in=df["classification"].dropna()).values_list("id", "name"))
173
+ )
174
+ elif self.group_by == AssetPositionGroupBy.CASH:
175
+ df.loc[df["id"], "label"] = "Cash"
176
+ df.loc[~df["id"], "label"] = "Non-Cash"
177
+ elif self.group_by == AssetPositionGroupBy.COUNTRY:
178
+ df["label"] = df["id"].map(dict(Geography.objects.filter(id__in=df["id"]).values_list("id", "name")))
179
+ elif self.group_by == AssetPositionGroupBy.CURRENCY:
180
+ currencies = dict(map(lambda o: (o.id, str(o)), Currency.objects.filter(id__in=df["id"])))
181
+ df["label"] = df["id"].map(currencies)
182
+ elif self.group_by == AssetPositionGroupBy.INSTRUMENT_TYPE:
183
+ df["label"] = df["id"].map(
184
+ dict(InstrumentType.objects.filter(id__in=df["id"]).values_list("id", "short_name"))
122
185
  )
186
+ df.loc[df["id"] == -1, "label"] = "N/A"
187
+ df.sort_values(by="weighting", ascending=False, inplace=True)
123
188
  return df
124
189
 
125
190
 
@@ -142,39 +207,17 @@ class DistributionChartViewSet(AbstractDistributionMixin, viewsets.ChartViewSet)
142
207
 
143
208
  def get_plotly(self, queryset):
144
209
  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}")
210
+ df = self.manipulate_dataframe(self.get_dataframe(self.request, queryset))
211
+ df = df.dropna(how="any")
212
+ if not df.empty:
213
+ levels = list(self.columns_map.keys())
214
+ fig = px.sunburst(
215
+ df,
216
+ path=levels,
217
+ values="weighting",
218
+ hover_data={"weighting": ":.2%"},
219
+ )
220
+ fig.update_traces(hovertemplate="<b>%{label}</b><br>Weight = %{customdata:.3p}")
178
221
  return fig
179
222
 
180
223
 
@@ -185,65 +228,212 @@ class DistributionTableViewSet(AbstractDistributionMixin, ExportPandasAPIViewSet
185
228
  button_config_class = DistributionTableButtonConfig
186
229
 
187
230
  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
231
+ fields = [
232
+ pf.PKField(key="id", label="id"),
233
+ pf.FloatField(key="weighting", label="Weight", precision=2, percent=True),
234
+ ]
235
+ for key, label in self.columns_map.items():
236
+ fields.append(pf.CharField(key=key, label=label))
237
+ return pf.PandasFields(fields=fields)
238
+
239
+ def get_aggregates(self, request, df):
240
+ return {"weighting": {"Σ": format_number(df["weighting"].sum())}}
241
+
242
+
243
+ # ##### CHART VIEWS #####
244
+
245
+
246
+ class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
247
+ filterset_class = ContributionChartFilter
248
+ filter_backends = (DjangoFilterBackend,)
249
+ IDENTIFIER = "wbportfolio:portfolio-contributor"
250
+ queryset = AssetPosition.objects.all()
251
+
252
+ title_config_class = ContributorPortfolioChartTitleConfig
253
+ endpoint_config_class = ContributorPortfolioChartEndpointConfig
254
+
255
+ ROW_HEIGHT: int = 20
256
+
257
+ @property
258
+ def min_height(self):
259
+ if hasattr(self, "nb_rows"):
260
+ return self.nb_rows * self.ROW_HEIGHT
261
+ return "300px"
262
+
263
+ @cached_property
264
+ def hedged_currency(self) -> Currency | None:
265
+ if "hedged_currency" in self.request.GET:
266
+ with suppress(Currency.DoesNotExist):
267
+ return Currency.objects.get(pk=self.request.GET["hedged_currency"])
268
+
269
+ @cached_property
270
+ def show_lookthrough(self) -> bool:
271
+ return self.portfolio.is_composition and self.request.GET.get("show_lookthrough", "false").lower() == "true"
272
+
273
+ def get_filterset_class(self, request):
274
+ if self.portfolio.is_composition:
275
+ return CompositionContributionChartFilter
276
+ return ContributionChartFilter
277
+
278
+ def get_plotly(self, queryset):
279
+ fig = go.Figure()
280
+ data = []
281
+ if self.show_lookthrough:
282
+ d1, d2 = get_date_interval_from_request(self.request)
283
+ for _d in pd.date_range(d1, d2):
284
+ for pos in self.portfolio.get_lookthrough_positions(_d.date()):
285
+ data.append(
286
+ [
287
+ pos.date,
288
+ pos.initial_price,
289
+ pos.initial_currency_fx_rate,
290
+ pos.underlying_instrument_id,
291
+ pos.weighting,
292
+ ]
293
+ )
207
294
  else:
208
- val_date = dt.date.today()
209
- return val_date
295
+ data = queryset.annotate_hedged_currency_fx_rate(self.hedged_currency).values_list(
296
+ "date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
297
+ )
298
+ df = Portfolio.get_contribution_df(data).rename(columns={"group_key": "underlying_instrument"})
299
+ if not df.empty:
300
+ df = df[["contribution_total", "contribution_forex", "underlying_instrument"]].sort_values(
301
+ by="contribution_total", ascending=True
302
+ )
303
+
304
+ df["instrument_id"] = df.underlying_instrument.map(
305
+ dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
306
+ )
307
+ df_forex = df[["instrument_id", "contribution_forex"]]
308
+ df_forex = df_forex[df_forex.contribution_forex != 0]
309
+
310
+ contribution_equity = df.contribution_total - df.contribution_forex
311
+
312
+ text_forex = df_forex.contribution_forex.apply(lambda x: f"{x:,.2%}")
313
+ text_equity = contribution_equity.apply(lambda x: f"{x:,.2%}")
314
+ self.nb_rows = df.shape[0]
315
+ fig.add_trace(
316
+ go.Bar(
317
+ y=df.instrument_id,
318
+ x=contribution_equity,
319
+ name="Contribution Equity",
320
+ orientation="h",
321
+ marker=dict(
322
+ color="rgba(247,110,91,0.6)",
323
+ line=dict(color="rgb(247,110,91,1.0)", width=2),
324
+ ),
325
+ text=text_equity.values,
326
+ textposition="auto",
327
+ )
328
+ )
329
+ fig.add_trace(
330
+ go.Bar(
331
+ y=df_forex.instrument_id,
332
+ x=df_forex.contribution_forex,
333
+ name="Contribution Forex",
334
+ orientation="h",
335
+ marker=dict(
336
+ color="rgba(58, 71, 80, 0.6)",
337
+ line=dict(color="rgba(58, 71, 80, 1.0)", width=2),
338
+ ),
339
+ text=text_forex.values,
340
+ textposition="outside",
341
+ )
342
+ )
343
+ fig.update_layout(
344
+ barmode="relative",
345
+ xaxis=dict(showgrid=False, showline=False, zeroline=False, tickformat=".2%"),
346
+ yaxis=dict(showgrid=False, showline=False, zeroline=False, tickmode="linear"),
347
+ margin=dict(b=0, r=20, l=20, t=0, pad=20),
348
+ paper_bgcolor="rgba(0,0,0,0)",
349
+ plot_bgcolor="rgba(0,0,0,0)",
350
+ font=dict(family="roboto", size=12, color="black"),
351
+ bargap=0.3,
352
+ )
353
+ # fig = get_horizontal_barplot(df, x_label="contribution_total", y_label="name")
354
+ return fig
355
+
356
+ def parse_figure_dict(self, figure_dict: dict[str, any]) -> dict[str, any]:
357
+ figure_dict = super().parse_figure_dict(figure_dict)
358
+ figure_dict["style"]["minHeight"] = self.min_height
359
+ return figure_dict
360
+
361
+ def get_queryset(self):
362
+ if self.has_portfolio_access:
363
+ return super().get_queryset().filter(portfolio=self.portfolio)
364
+ return AssetPosition.objects.none()
365
+
366
+
367
+ class AssetPositionUnderlyingInstrumentChartViewSet(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
368
+ IDENTIFIER = "wbportfolio:assetpositionchart"
369
+
370
+ queryset = AssetPosition.objects.all()
371
+
372
+ title_config_class = AssetPositionUnderlyingInstrumentChartTitleConfig
373
+ endpoint_config_class = AssetPositionUnderlyingInstrumentChartEndpointConfig
374
+ filterset_class = AssetPositionUnderlyingInstrumentChartFilter
210
375
 
211
376
  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"))
377
+ return AssetPosition.objects.filter(underlying_quote__in=self.instrument.get_descendants(include_self=True))
378
+
379
+ def get_plotly(self, queryset):
380
+ fig = make_subplots(specs=[[{"secondary_y": True}]])
381
+ fig = get_default_timeserie_figure(fig)
382
+ if queryset.exists():
383
+ df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
384
+ df_weight = df_weight.where(pd.notnull(df_weight), 0)
385
+ df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
386
+ min_date = df_weight["date"].min()
387
+ max_date = df_weight["date"].max()
388
+
389
+ df_price = (
390
+ pd.DataFrame(
391
+ self.instrument.prices.filter_only_valid_prices()
392
+ .annotate_base_data()
393
+ .filter(date__gte=min_date, date__lte=max_date)
394
+ .values_list("date", "net_value_usd"),
395
+ columns=["date", "price_fx_usd"],
232
396
  )
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
397
+ .set_index("date")
398
+ .sort_index()
399
+ )
242
400
 
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
401
+ fig.add_trace(
402
+ go.Scatter(
403
+ x=df_price.index, y=df_price.price_fx_usd, mode="lines", marker_color="green", name="Price"
404
+ ),
405
+ secondary_y=False,
406
+ )
407
+
408
+ df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
409
+ df_weight = df_weight.where(pd.notnull(df_weight), 0)
410
+ df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
411
+ for portfolio_name, df_tmp in df_weight.groupby("portfolio__name"):
412
+ fig.add_trace(
413
+ go.Scatter(
414
+ x=df_tmp.date,
415
+ y=df_tmp.weighting,
416
+ hovertemplate=get_hovertemplate_timeserie(is_percent=True),
417
+ mode="lines",
418
+ name=f"Allocation: {portfolio_name}",
419
+ ),
420
+ secondary_y=True,
421
+ )
422
+
423
+ # Set x-axis title
424
+ fig.update_xaxes(title_text="Date")
425
+ # Set y-axes titles
426
+ fig.update_yaxes(
427
+ title_text="<b>Price</b>",
428
+ secondary_y=False,
429
+ titlefont=dict(color="green"),
430
+ tickfont=dict(color="green"),
431
+ )
432
+ fig.update_yaxes(
433
+ title_text="<b>Portfolio Allocation (%)</b>",
434
+ secondary_y=True,
435
+ titlefont=dict(color="blue"),
436
+ tickfont=dict(color="blue"),
437
+ )
438
+
439
+ return fig
@@ -15,8 +15,8 @@ from .products import ProductButtonConfig, ProductCustomerButtonConfig
15
15
  from .registers import RegisterButtonConfig
16
16
  from .trades import (
17
17
  TradeButtonConfig,
18
- TradeInstrumentButtonConfig,
18
+ TradeInstrumentButtonConfig
19
19
  )
20
- from .trade_proposals import TradeProposalButtonConfig
20
+
21
21
  from .reconciliations import AccountReconciliationButtonViewConfig, AccountReconciliationLineButtonViewConfig
22
22
  from .signals import *
@@ -96,7 +96,7 @@ class AssetPositionPortfolioButtonConfig(AssetPositionButtonConfig):
96
96
  request=self.request,
97
97
  )
98
98
  ),
99
- label=f"{PortfolioPortfolioThroughModel.Type[rel.type].label} Portfolio",
99
+ label=f"Dependency Portfolio ({PortfolioPortfolioThroughModel.Type[rel.type].label})",
100
100
  )
101
101
  )
102
102
  return set(btns)
@@ -7,7 +7,7 @@ from wbfdm.models.instruments import Instrument
7
7
 
8
8
  class InstrumentButtonMixin:
9
9
  @classmethod
10
- def add_instrument_request_button(self, request=None, view=None, pk=None, **kwargs):
10
+ def add_instrument_request_button(cls, request=None, view=None, pk=None, **kwargs):
11
11
  buttons = [
12
12
  bt.WidgetButton(key="assets", label="Implemented Portfolios (Assets)"),
13
13
  # bt.WidgetButton(
@@ -44,7 +44,7 @@ class InstrumentButtonMixin:
44
44
  )
45
45
 
46
46
  @classmethod
47
- def add_transactions_request_button(self, request=None, view=None, pk=None, **kwargs):
47
+ def add_transactions_request_button(cls, request=None, view=None, pk=None, **kwargs):
48
48
  return bt.DropDownButton(
49
49
  label="Transactions",
50
50
  icon=WBIcon.UNFOLD.icon,
@@ -53,8 +53,8 @@ class InstrumentButtonMixin:
53
53
  bt.WidgetButton(key="portfolio_trades", label="Trades"),
54
54
  bt.WidgetButton(key="instrument_subscriptionsredemptions", label="Subscriptions/Redemptions"),
55
55
  bt.WidgetButton(key="instrument_trades", label="Trades (Implemented)"),
56
- bt.WidgetButton(key="portfolio_fees", label="Fees"),
57
- bt.WidgetButton(key="portfolio_aggregatedfees", label="Aggregated Fees"),
56
+ bt.WidgetButton(key="product_fees", label="Fees"),
57
+ bt.WidgetButton(key="product_aggregatedfees", label="Aggregated Fees"),
58
58
  bt.DropDownButton(
59
59
  label="Charts",
60
60
  icon=WBIcon.UNFOLD.icon,