wbportfolio 1.55.8__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 (128) hide show
  1. wbportfolio/admin/orders/order_proposals.py +2 -0
  2. wbportfolio/admin/orders/orders.py +2 -0
  3. wbportfolio/admin/portfolio.py +11 -5
  4. wbportfolio/api_clients/ubs.py +23 -11
  5. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  6. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  7. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  8. wbportfolio/contrib/company_portfolio/models.py +69 -39
  9. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  10. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  11. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  12. wbportfolio/factories/assets.py +1 -1
  13. wbportfolio/factories/orders/order_proposals.py +3 -1
  14. wbportfolio/factories/orders/orders.py +8 -3
  15. wbportfolio/factories/product_groups.py +3 -3
  16. wbportfolio/factories/products.py +3 -3
  17. wbportfolio/filters/assets.py +0 -1
  18. wbportfolio/filters/orders/order_proposals.py +3 -6
  19. wbportfolio/filters/portfolios.py +18 -1
  20. wbportfolio/filters/positions.py +0 -1
  21. wbportfolio/filters/transactions/fees.py +0 -2
  22. wbportfolio/filters/transactions/trades.py +0 -1
  23. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  24. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  25. wbportfolio/import_export/handlers/asset_position.py +9 -5
  26. wbportfolio/import_export/handlers/dividend.py +1 -1
  27. wbportfolio/import_export/handlers/fees.py +2 -2
  28. wbportfolio/import_export/handlers/trade.py +4 -4
  29. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  30. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  31. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  32. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  33. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  34. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  35. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  36. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  37. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  38. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  39. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  40. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  41. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  42. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  43. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  44. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  45. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  46. wbportfolio/import_export/resources/trades.py +1 -1
  47. wbportfolio/import_export/utils.py +3 -1
  48. wbportfolio/metric/backends/base.py +2 -2
  49. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  50. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  51. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  52. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  53. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  54. wbportfolio/models/adjustments.py +1 -1
  55. wbportfolio/models/asset.py +7 -3
  56. wbportfolio/models/builder.py +25 -5
  57. wbportfolio/models/custodians.py +3 -3
  58. wbportfolio/models/exceptions.py +1 -1
  59. wbportfolio/models/graphs/portfolio.py +1 -1
  60. wbportfolio/models/graphs/utils.py +11 -11
  61. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  62. wbportfolio/models/orders/order_proposals.py +620 -490
  63. wbportfolio/models/orders/orders.py +237 -75
  64. wbportfolio/models/portfolio.py +79 -18
  65. wbportfolio/models/portfolio_relationship.py +6 -0
  66. wbportfolio/models/products.py +3 -0
  67. wbportfolio/models/rebalancing.py +4 -1
  68. wbportfolio/models/roles.py +4 -10
  69. wbportfolio/models/transactions/claim.py +6 -5
  70. wbportfolio/models/transactions/dividends.py +1 -0
  71. wbportfolio/models/transactions/trades.py +4 -0
  72. wbportfolio/models/transactions/transactions.py +16 -4
  73. wbportfolio/models/utils.py +100 -1
  74. wbportfolio/order_routing/__init__.py +16 -0
  75. wbportfolio/order_routing/adapters/__init__.py +14 -6
  76. wbportfolio/order_routing/adapters/ubs.py +104 -70
  77. wbportfolio/order_routing/router.py +33 -0
  78. wbportfolio/order_routing/tests/test_router.py +110 -0
  79. wbportfolio/permissions.py +7 -0
  80. wbportfolio/pms/trading/__init__.py +0 -1
  81. wbportfolio/pms/trading/optimizer.py +61 -0
  82. wbportfolio/pms/typing.py +115 -103
  83. wbportfolio/rebalancing/models/composite.py +1 -1
  84. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  85. wbportfolio/risk_management/backends/__init__.py +1 -0
  86. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  87. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  88. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  89. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  90. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  91. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  92. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  93. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  94. wbportfolio/serializers/orders/order_proposals.py +6 -2
  95. wbportfolio/serializers/orders/orders.py +119 -26
  96. wbportfolio/serializers/transactions/claim.py +2 -2
  97. wbportfolio/tasks.py +42 -4
  98. wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
  99. wbportfolio/tests/models/test_portfolios.py +9 -9
  100. wbportfolio/tests/models/test_splits.py +1 -6
  101. wbportfolio/tests/models/test_utils.py +140 -0
  102. wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
  103. wbportfolio/tests/rebalancing/test_models.py +2 -2
  104. wbportfolio/tests/viewsets/test_products.py +1 -0
  105. wbportfolio/urls.py +1 -1
  106. wbportfolio/viewsets/charts/assets.py +8 -4
  107. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  108. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  109. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  110. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  111. wbportfolio/viewsets/esg.py +3 -5
  112. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
  113. wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
  114. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
  115. wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
  116. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
  117. wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
  118. wbportfolio/viewsets/orders/order_proposals.py +92 -21
  119. wbportfolio/viewsets/orders/orders.py +79 -26
  120. wbportfolio/viewsets/portfolios.py +24 -0
  121. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
  122. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
  123. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  124. wbportfolio/fdm/tasks.py +0 -42
  125. wbportfolio/models/orders/routing.py +0 -54
  126. wbportfolio/pms/trading/handler.py +0 -211
  127. /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
  128. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -49,8 +49,8 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
49
49
  ),
50
50
  dp.LegendItem(
51
51
  icon=WBColor.GREEN.value,
52
- label=OrderProposal.Status.APPLIED.label,
53
- value=OrderProposal.Status.APPLIED.value,
52
+ label=OrderProposal.Status.CONFIRMED.label,
53
+ value=OrderProposal.Status.CONFIRMED.value,
54
54
  ),
55
55
  dp.LegendItem(
56
56
  icon=WBColor.GREY.value,
@@ -78,7 +78,7 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
78
78
  ),
79
79
  dp.FormattingRule(
80
80
  style={"backgroundColor": WBColor.GREEN.value},
81
- condition=("==", OrderProposal.Status.APPLIED.value),
81
+ condition=("==", OrderProposal.Status.CONFIRMED.value),
82
82
  ),
83
83
  dp.FormattingRule(
84
84
  style={"backgroundColor": WBColor.GREY.value},
@@ -125,33 +125,33 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
125
125
  ),
126
126
  )
127
127
  )
128
- return Display(
129
- pages=[
130
- Page(
131
- title="Main Information",
132
- layouts={
133
- default(): Layout(
134
- grid_template_areas=[
135
- ["status", "status", "status"],
136
- ["trade_date", "total_cash_weight", "min_order_value"],
137
- ["rebalancing_model", "target_portfolio", "target_portfolio"]
138
- if self.view.new_mode
139
- else ["rebalancing_model", "creator", "approver"],
140
- ["comment", "comment", "comment"],
141
- ],
142
- ),
143
- },
128
+
129
+ main_info_page = Page(
130
+ title="Main Information",
131
+ layouts={
132
+ default(): Layout(
133
+ grid_template_areas=[
134
+ ["status", "status", "status", "status"],
135
+ ["trade_date", "total_cash_weight", "min_order_value", "min_weighting"],
136
+ ["rebalancing_model", "rebalancing_model", "target_portfolio", "target_portfolio"]
137
+ if self.view.new_mode
138
+ else ["rebalancing_model", "rebalancing_model", "creator", "approver"],
139
+ ["comment", "comment", "comment", "comment"],
140
+ ],
144
141
  ),
145
- Page(
146
- title="Orders",
147
- layouts={
148
- default(): Layout(
149
- grid_template_areas=orders_grid_template_areas,
150
- grid_template_rows=orders_grid_template_rows,
151
- inlines=[Inline(key="orders", endpoint="orders")],
152
- sections=sections,
153
- ),
154
- },
142
+ },
143
+ )
144
+ orders_page = Page(
145
+ title="Orders",
146
+ layouts={
147
+ default(): Layout(
148
+ grid_template_areas=orders_grid_template_areas,
149
+ grid_template_rows=orders_grid_template_rows,
150
+ inlines=[Inline(key="orders", endpoint="orders")],
151
+ sections=sections,
155
152
  ),
156
- ]
153
+ },
154
+ )
155
+ return Display(
156
+ pages=[orders_page, main_info_page] if "pk" in self.view.kwargs else [main_info_page, orders_page]
157
157
  )
@@ -71,17 +71,27 @@ class OrderOrderProposalDisplayConfig(DisplayViewConfig):
71
71
  fields = [
72
72
  dp.Field(
73
73
  label="Instrument",
74
- open_by_default=True,
74
+ open_by_default=False,
75
75
  key=None,
76
76
  children=[
77
77
  dp.Field(key="underlying_instrument", label="Name", width=Unit.PIXEL(250)),
78
78
  dp.Field(key="underlying_instrument_isin", label="ISIN", width=Unit.PIXEL(125)),
79
- dp.Field(key="underlying_instrument_ticker", label="Ticker", width=Unit.PIXEL(100)),
79
+ dp.Field(key="underlying_instrument_ticker", label="Ticker", width=Unit.PIXEL(100), show="open"),
80
+ dp.Field(
81
+ key="underlying_instrument_refinitiv_identifier_code",
82
+ label="RIC",
83
+ width=Unit.PIXEL(100),
84
+ show="open",
85
+ ),
80
86
  dp.Field(
81
- key="underlying_instrument_refinitiv_identifier_code", label="RIC", width=Unit.PIXEL(100)
87
+ key="underlying_instrument_instrument_type",
88
+ label="Asset Class",
89
+ width=Unit.PIXEL(125),
90
+ show="open",
91
+ ),
92
+ dp.Field(
93
+ key="underlying_instrument_exchange", label="Exchange", width=Unit.PIXEL(125), show="open"
82
94
  ),
83
- dp.Field(key="underlying_instrument_instrument_type", label="Asset Class", width=Unit.PIXEL(125)),
84
- dp.Field(key="underlying_instrument_exchange", label="Exchange", width=Unit.PIXEL(125)),
85
95
  ],
86
96
  ),
87
97
  dp.Field(
@@ -157,23 +167,52 @@ class OrderOrderProposalDisplayConfig(DisplayViewConfig):
157
167
  formatting_rules=ORDER_TYPE_FORMATTING_RULES,
158
168
  width=Unit.PIXEL(125),
159
169
  ),
160
- dp.Field(key="comment", label="Comment", width=Unit.PIXEL(250)),
161
- dp.Field(key="order", label="Order", show="open", width=Unit.PIXEL(100)),
170
+ dp.Field(
171
+ key="desired_target_weight", label="Desired Target Weight", show="open", width=Unit.PIXEL(100)
172
+ ),
173
+ dp.Field(key="daily_return", label="Daily Return", show="open", width=Unit.PIXEL(100)),
174
+ dp.Field(key="currency_fx_rate", label="FX Rate", show="open", width=Unit.PIXEL(100)),
175
+ dp.Field(key="price", label="Price", show="open", width=Unit.PIXEL(100)),
176
+ dp.Field(key="order", label="Order", show="open", width=Unit.PIXEL(50)),
177
+ dp.Field(key="comment", label="Comment", show="open", width=Unit.PIXEL(250)),
162
178
  ],
163
179
  )
164
180
  )
181
+ execution_fields = [
182
+ dp.Field(
183
+ label="Instruction",
184
+ open_by_default=False,
185
+ key=None,
186
+ children=[
187
+ dp.Field(key="execution_instruction", label="Type", width=Unit.PIXEL(125)),
188
+ dp.Field(
189
+ key="execution_instruction_parameters_repr",
190
+ label="Parameters",
191
+ width=Unit.PIXEL(125),
192
+ show="open",
193
+ ),
194
+ ],
195
+ )
196
+ ]
197
+
165
198
  if order_proposal.execution_status:
166
- fields.append(
167
- dp.Field(
168
- label="Execution",
169
- open_by_default=True,
170
- key=None,
171
- children=[
172
- dp.Field(key="execution_confirmed", label="Confirmed", width=Unit.PIXEL(50)),
173
- dp.Field(key="execution_comment", label="Comment", width=Unit.PIXEL(100)),
174
- ],
175
- )
199
+ execution_fields.extend(
200
+ [
201
+ dp.Field(key="execution_status", label="Status", width=Unit.PIXEL(100)),
202
+ dp.Field(key="execution_comment", label="Comment", width=Unit.PIXEL(150), show="open"),
203
+ dp.Field(
204
+ label="Trade",
205
+ open_by_default=False,
206
+ key=None,
207
+ children=[
208
+ dp.Field(key="execution_date", label="Date", width=Unit.PIXEL(100), show="open"),
209
+ dp.Field(key="execution_price", label="Price", width=Unit.PIXEL(100), show="open"),
210
+ dp.Field(key="execution_traded_shares", label="Shares", width=Unit.PIXEL(100)),
211
+ ],
212
+ ),
213
+ ]
176
214
  )
215
+ fields.append(dp.Field(label="Execution", open_by_default=False, key=None, children=execution_fields))
177
216
  return dp.ListDisplay(
178
217
  fields=fields,
179
218
  legends=[ORDER_STATUS_LEGENDS],
@@ -9,7 +9,7 @@ class OrderProposalEndpointConfig(EndpointViewConfig):
9
9
  def get_delete_endpoint(self, **kwargs):
10
10
  if pk := self.view.kwargs.get("pk", None):
11
11
  order_proposal = get_object_or_404(OrderProposal, pk=pk)
12
- if order_proposal.status == OrderProposal.Status.DRAFT:
12
+ if order_proposal.status in [OrderProposal.Status.DRAFT, OrderProposal.Status.DENIED]:
13
13
  return super().get_endpoint()
14
14
  return None
15
15
 
@@ -1,5 +1,3 @@
1
- from contextlib import suppress
2
-
3
1
  from rest_framework.reverse import reverse
4
2
  from wbcore.metadata.configs.endpoints import EndpointViewConfig
5
3
 
@@ -18,9 +16,13 @@ class OrderOrderProposalEndpointConfig(EndpointViewConfig):
18
16
  )
19
17
  return None
20
18
 
21
- def get_delete_endpoint(self, **kwargs):
22
- with suppress(AttributeError, AssertionError):
23
- order = self.view.get_object()
24
- if order._effective_weight: # we make sure order with a valid effective position cannot be deleted
25
- return None
26
- return super().get_delete_endpoint(**kwargs)
19
+ def get_update_endpoint(self, **kwargs):
20
+ if order_proposal_id := self.view.kwargs.get("order_proposal_id", None):
21
+ order_proposal = OrderProposal.objects.get(id=order_proposal_id)
22
+ if order_proposal.status == OrderProposal.Status.DRAFT or order_proposal.can_be_confirmed:
23
+ return reverse(
24
+ "wbportfolio:orderproposal-order-list",
25
+ args=[self.view.kwargs["order_proposal_id"]],
26
+ request=self.request,
27
+ )
28
+ return None
@@ -2,7 +2,7 @@ from contextlib import suppress
2
2
  from datetime import date
3
3
  from decimal import Decimal
4
4
 
5
- from django.contrib.messages import info, warning
5
+ from django.contrib.messages import error, warning
6
6
  from django.shortcuts import get_object_or_404
7
7
  from django.utils.functional import cached_property
8
8
  from pandas._libs.tslibs.offsets import BDay
@@ -19,8 +19,9 @@ from wbcore.metadata.configs.display.instance_display import (
19
19
  from wbcore.permissions.permissions import InternalUserPermissionMixin
20
20
  from wbcore.utils.views import CloneMixin
21
21
 
22
- from wbportfolio.models import AssetPosition, OrderProposal
22
+ from wbportfolio.models import AssetPosition, Order, OrderProposal
23
23
  from wbportfolio.models.orders.order_proposals import (
24
+ execute_orders_as_task,
24
25
  push_model_change_as_task,
25
26
  replay_as_task,
26
27
  )
@@ -31,7 +32,8 @@ from wbportfolio.serializers import (
31
32
  )
32
33
 
33
34
  from ...filters.orders import OrderProposalFilterSet
34
- from ...order_routing import ExecutionStatus
35
+ from ...order_routing import ExecutionStatus, RoutingException
36
+ from ...permissions import IsPortfolioManager
35
37
  from ..mixins import UserPortfolioRequestPermissionMixin
36
38
  from .configs import (
37
39
  OrderProposalButtonConfig,
@@ -48,6 +50,7 @@ class OrderProposalRepresentationViewSet(InternalUserPermissionMixin, viewsets.R
48
50
 
49
51
 
50
52
  class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserPermissionMixin, viewsets.ModelViewSet):
53
+ IDENTIFIER = "wbportfolio:order"
51
54
  ordering_fields = ("trade_date",)
52
55
  ordering = ("-trade_date",)
53
56
  search_fields = ("comment",)
@@ -87,16 +90,16 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
87
90
 
88
91
  def add_messages(self, request, instance: OrderProposal | None = None, **kwargs):
89
92
  if instance:
90
- if instance.status == OrderProposal.Status.APPROVED and not instance.portfolio.is_manageable:
91
- info(request, "This order proposal cannot be approved the portfolio is considered unmanaged.")
92
93
  if instance.status == OrderProposal.Status.PENDING and instance.has_non_successful_checks:
93
94
  warning(
94
95
  request,
95
96
  "This order proposal cannot be approved because there is unsuccessful pre-trade checks. Please rectify accordingly and resubmit a valid order proposal",
96
97
  )
97
98
  if (
98
- instance.execution_status in [ExecutionStatus.IN_DRAFT, ExecutionStatus.COMPLETED]
99
- and instance.orders.filter(execution_confirmed=False).exists()
99
+ instance.status == OrderProposal.Status.EXECUTION
100
+ and instance.orders.exclude(shares=0, weighting=0)
101
+ .filter(execution_status=Order.ExecutionStatus.FAILED)
102
+ .exists()
100
103
  ):
101
104
  warning(request, "Some orders failed confirmation. Check the list for further details.")
102
105
  if instance.execution_status in [
@@ -108,12 +111,17 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
108
111
  request,
109
112
  f"The execution status is {ExecutionStatus[instance.execution_status].label}. Detail: {instance.execution_comment}",
110
113
  )
114
+ elif instance.can_be_executed and instance.approver == request.user.profile:
115
+ warning(
116
+ request,
117
+ "As the approver of these orders, you are not authorized to execute them yourself. Please assign execution to another qualified individual.",
118
+ )
111
119
 
112
120
  @classmethod
113
121
  def _get_risk_checks_button_title(cls) -> str:
114
122
  return "Pre-Trade Checks"
115
123
 
116
- @action(detail=True, methods=["PATCH"])
124
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
117
125
  def reset(self, request, pk=None):
118
126
  order_proposal = get_object_or_404(OrderProposal, pk=pk)
119
127
  use_desired_target_weight = request.GET.get("use_desired_target_weight") == "true"
@@ -123,16 +131,16 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
123
131
  return Response({"send": True})
124
132
  return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
125
133
 
126
- @action(detail=True, methods=["PATCH"])
134
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
127
135
  def normalize(self, request, pk=None):
128
136
  order_proposal = get_object_or_404(OrderProposal, pk=pk)
129
137
  total_cash_weight = Decimal(request.data.get("total_cash_weight", Decimal("0.0")))
130
138
  if order_proposal.status == OrderProposal.Status.DRAFT:
131
- order_proposal.normalize_orders(total_target_weight=Decimal("1.0") - total_cash_weight)
139
+ order_proposal.normalize_orders(total_cash_weight)
132
140
  return Response({"send": True})
133
141
  return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
134
142
 
135
- @action(detail=True, methods=["PATCH"])
143
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
136
144
  def replay(self, request, pk=None):
137
145
  order_proposal = get_object_or_404(OrderProposal, pk=pk)
138
146
  if order_proposal.portfolio.is_manageable:
@@ -140,22 +148,14 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
140
148
  return Response({"send": True})
141
149
  return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
142
150
 
143
- @action(detail=True, methods=["PATCH"])
144
- def deleteall(self, request, pk=None):
145
- order_proposal = get_object_or_404(OrderProposal, pk=pk)
146
- if order_proposal.status == OrderProposal.Status.DRAFT:
147
- order_proposal.orders.all().delete()
148
- return Response({"send": True})
149
- return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
150
-
151
- @action(detail=True, methods=["PATCH"])
151
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
152
152
  def pushmodelchange(self, request, pk=None):
153
153
  order_proposal = get_object_or_404(OrderProposal, pk=pk)
154
154
  only_for_portfolio_ids = list(
155
155
  map(lambda o: int(o), filter(lambda r: r, request.data.get("only_for_portfolio_ids", "").split(",")))
156
156
  )
157
157
  approve_automatically = request.data.get("approve_automatically") == "true"
158
- if order_proposal.status == OrderProposal.Status.APPLIED and order_proposal.portfolio.is_model:
158
+ if order_proposal.status == OrderProposal.Status.APPROVED and order_proposal.portfolio.is_model:
159
159
  push_model_change_as_task.delay(
160
160
  order_proposal.id,
161
161
  request.user.id,
@@ -168,6 +168,77 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
168
168
  status=status.HTTP_400_BAD_REQUEST,
169
169
  )
170
170
 
171
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
172
+ def execute(self, request, pk=None):
173
+ order_proposal = get_object_or_404(OrderProposal, pk=pk)
174
+ if order_proposal.can_execute(request.user):
175
+ prioritize_target_weight = request.data.get("prioritize_target_weight") == "true"
176
+ order_proposal.execution_status = ExecutionStatus.PENDING
177
+ order_proposal.execution_comment = "Waiting for custodian confirmation"
178
+ order_proposal.save()
179
+ execute_orders_as_task.delay(order_proposal.id, prioritize_target_weight=prioritize_target_weight)
180
+ return Response({"send": True})
181
+ return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
182
+
183
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
184
+ def cancelexecution(self, request, pk=None):
185
+ order_proposal = get_object_or_404(OrderProposal, pk=pk)
186
+ if order_proposal.execution_status and order_proposal.execution_status != ExecutionStatus.CANCELLED:
187
+ try:
188
+ if not order_proposal.cancel_rebalancing():
189
+ warning(
190
+ request,
191
+ "We could not cancel the rebalancing. It is probably already executed. Please refresh status or check with an administrator.",
192
+ )
193
+ except (RoutingException, ValueError) as e:
194
+ error(request, f"Could not cancel orders proposal {order_proposal}: {str(e)}")
195
+ return Response({"send": True})
196
+ return Response(
197
+ {"status": "Order Proposal is not in an execution phase, therefore, it cannot be cancelled."},
198
+ status=status.HTTP_400_BAD_REQUEST,
199
+ )
200
+
201
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
202
+ def updateexecutionstatus(self, request, pk=None):
203
+ order_proposal = get_object_or_404(OrderProposal, pk=pk)
204
+ if order_proposal.execution_status:
205
+ try:
206
+ if not order_proposal.custodian_router:
207
+ raise RoutingException(
208
+ "There is no custodian router for this portfolio. Please check with an administrator."
209
+ )
210
+ order_proposal.refresh_execution_status()
211
+ except (RoutingException, ValueError) as e:
212
+ error(request, f"Could not update rebalancing status: {str(e)}")
213
+ return Response(
214
+ {
215
+ "send": True,
216
+ }
217
+ )
218
+ return Response(
219
+ {"status": "Order Proposal is not in an execution phase, therefore, its status cannot be fetched."},
220
+ status=status.HTTP_400_BAD_REQUEST,
221
+ )
222
+
223
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
224
+ def refreshreturn(self, request, pk=None):
225
+ order_proposal = get_object_or_404(OrderProposal, pk=pk)
226
+ order_proposal.refresh_returns()
227
+ return Response(
228
+ {"status": "Returns were refreshed with success"},
229
+ status=status.HTTP_200_OK,
230
+ )
231
+
232
+ @action(detail=True, methods=["PATCH"], permission_classes=[IsPortfolioManager])
233
+ def refreshpretradechecks(self, request, pk=None):
234
+ order_proposal = get_object_or_404(OrderProposal, pk=pk)
235
+ if order_proposal.status == OrderProposal.Status.DRAFT:
236
+ order_proposal.evaluate_pretrade_checks()
237
+ return Response(
238
+ {"status": "Evaluate pretrade checks"},
239
+ status=status.HTTP_200_OK,
240
+ )
241
+
171
242
 
172
243
  class OrderProposalPortfolioModelViewSet(UserPortfolioRequestPermissionMixin, OrderProposalModelViewSet):
173
244
  endpoint_config_class = OrderProposalPortfolioEndpointConfig
@@ -3,13 +3,17 @@ from decimal import Decimal
3
3
  from django.contrib.messages import error, info
4
4
  from django.db.models import (
5
5
  Case,
6
+ CharField,
6
7
  F,
8
+ Func,
7
9
  Sum,
8
10
  Value,
9
11
  When,
10
12
  )
11
13
  from django.shortcuts import get_object_or_404
12
14
  from django.utils.functional import cached_property
15
+ from rest_framework.decorators import action
16
+ from rest_framework.response import Response
13
17
  from wbcore import viewsets
14
18
  from wbcore.permissions.permissions import InternalUserPermissionMixin
15
19
  from wbcore.utils.strings import format_number
@@ -24,17 +28,20 @@ from wbportfolio.serializers import (
24
28
  )
25
29
 
26
30
  from ...filters.orders import OrderFilterSet
31
+ from ...permissions import IsPortfolioManager
27
32
  from ..mixins import UserPortfolioRequestPermissionMixin
28
33
  from .configs import (
29
34
  OrderOrderProposalButtonConfig,
30
35
  OrderOrderProposalDisplayConfig,
31
36
  OrderOrderProposalEndpointConfig,
32
37
  )
38
+ from .configs.buttons.orders import ExecutionInstructionSerializer
33
39
 
34
40
 
35
41
  class OrderOrderProposalModelViewSet(
36
42
  UserPortfolioRequestPermissionMixin, InternalUserPermissionMixin, OrderableMixin, viewsets.ModelViewSet
37
43
  ):
44
+ IDENTIFIER = "wbportfolio:order"
38
45
  IMPORT_ALLOWED = True
39
46
  ordering = (
40
47
  "order_proposal",
@@ -53,7 +60,7 @@ class OrderOrderProposalModelViewSet(
53
60
  "shares",
54
61
  "weighting",
55
62
  )
56
- IDENTIFIER = "wbportfolio:orderproposal"
63
+ IDENTIFIER = "wbportfolio:order"
57
64
  search_fields = ("underlying_instrument__name",)
58
65
  queryset = Order.objects.none()
59
66
  filterset_class = OrderFilterSet
@@ -92,10 +99,10 @@ class OrderOrderProposalModelViewSet(
92
99
  sum_effective_total_value_fx_portfolio=Sum(F("effective_total_value_fx_portfolio")),
93
100
  )
94
101
  # weights aggregates
95
- cash_sum_effective_weight = (
96
- self.order_proposal.total_effective_portfolio_weight - noncash_aggregates["sum_effective_weight"]
102
+ cash_sum_effective_weight = self.order_proposal.total_effective_portfolio_weight - (
103
+ noncash_aggregates["sum_effective_weight"] or Decimal(0)
97
104
  )
98
- cash_sum_target_cash_weight = Decimal("1.0") - noncash_aggregates["sum_target_weight"]
105
+ cash_sum_target_cash_weight = Decimal("1.0") - (noncash_aggregates["sum_target_weight"] or Decimal(0))
99
106
  noncash_sum_effective_weight = noncash_aggregates["sum_effective_weight"] or Decimal(0)
100
107
  noncash_sum_target_weight = noncash_aggregates["sum_target_weight"] or Decimal(0)
101
108
  sum_buy_weight = queryset.filter(weighting__gte=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
@@ -171,11 +178,25 @@ class OrderOrderProposalModelViewSet(
171
178
  return agg
172
179
 
173
180
  def get_serializer_class(self):
174
- if self.order_proposal.status != OrderProposal.Status.DRAFT:
181
+ if self.order_proposal.status != OrderProposal.Status.DRAFT and not self.order_proposal.can_be_confirmed:
175
182
  return ReadOnlyOrderOrderProposalModelSerializer
176
- elif not self.new_mode and "pk" not in self.kwargs:
177
- return OrderOrderProposalListModelSerializer
178
- return OrderOrderProposalModelSerializer
183
+ if not self.new_mode and "pk" not in self.kwargs:
184
+ serializer_base_class = OrderOrderProposalListModelSerializer
185
+ else:
186
+ serializer_base_class = OrderOrderProposalModelSerializer
187
+ if not self.order_proposal.portfolio_total_asset_value:
188
+
189
+ class OnlyWeightSerializerClass(serializer_base_class):
190
+ class Meta(serializer_base_class.Meta):
191
+ read_only_fields = list(serializer_base_class.Meta.read_only_fields) + [
192
+ "shares",
193
+ "target_shares",
194
+ "total_value_fx_portfolio",
195
+ "target_total_value_fx_portfolio",
196
+ ]
197
+
198
+ return OnlyWeightSerializerClass
199
+ return serializer_base_class
179
200
 
180
201
  def add_messages(self, request, queryset=None, paginated_queryset=None, instance=None, initial=False):
181
202
  if self.orders.exists() and self.order_proposal.status in [
@@ -202,23 +223,55 @@ class OrderOrderProposalModelViewSet(
202
223
  return qs
203
224
 
204
225
  def get_queryset(self):
205
- return self.orders.annotate( # .exclude(underlying_instrument__is_cash=True)
206
- underlying_instrument_isin=F("underlying_instrument__isin"),
207
- underlying_instrument_ticker=F("underlying_instrument__ticker"),
208
- underlying_instrument_refinitiv_identifier_code=F("underlying_instrument__refinitiv_identifier_code"),
209
- underlying_instrument_instrument_type=Case(
210
- When(
211
- underlying_instrument__parent__is_security=True,
212
- then=F("underlying_instrument__parent__instrument_type__short_name"),
226
+ return (
227
+ self.orders.filter(underlying_instrument__is_cash=False)
228
+ .annotate( # .exclude(underlying_instrument__is_cash=True)
229
+ underlying_instrument_isin=F("underlying_instrument__isin"),
230
+ underlying_instrument_ticker=F("underlying_instrument__ticker"),
231
+ underlying_instrument_refinitiv_identifier_code=F("underlying_instrument__refinitiv_identifier_code"),
232
+ underlying_instrument_instrument_type=Case(
233
+ When(
234
+ underlying_instrument__parent__is_security=True,
235
+ then=F("underlying_instrument__parent__instrument_type__short_name"),
236
+ ),
237
+ default=F("underlying_instrument__instrument_type__short_name"),
213
238
  ),
214
- default=F("underlying_instrument__instrument_type__short_name"),
215
- ),
216
- underlying_instrument_exchange=F("underlying_instrument__exchange__name"),
217
- effective_total_value_fx_portfolio=F("previous_weight") * Value(self.portfolio_total_asset_value),
218
- target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
219
- portfolio_currency=F("portfolio__currency__symbol"),
220
- security=F("underlying_instrument__parent"),
221
- company=F("underlying_instrument__parent__parent"),
222
- ).select_related(
223
- "underlying_instrument", "underlying_instrument__parent", "underlying_instrument__parent__parent"
239
+ underlying_instrument_exchange=F("underlying_instrument__exchange__name"),
240
+ effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
241
+ target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
242
+ portfolio_currency=F("portfolio__currency__symbol"),
243
+ underlying_instrument_currency=F("underlying_instrument__currency__symbol"),
244
+ security=F("underlying_instrument__parent"),
245
+ company=F("underlying_instrument__parent__parent"),
246
+ execution_instruction_parameters_repr=Func(
247
+ "execution_instruction_parameters",
248
+ function="string_agg",
249
+ template="(SELECT string_agg(key || '=' || value, ',') FROM jsonb_each_text(%(expressions)s))",
250
+ output_field=CharField(),
251
+ ),
252
+ execution_date=F("execution_trade__transaction_date"),
253
+ execution_price=F("execution_trade__price"),
254
+ execution_traded_shares=F("execution_trade__shares"),
255
+ )
256
+ .select_related(
257
+ "underlying_instrument", "underlying_instrument__parent", "underlying_instrument__parent__parent"
258
+ )
224
259
  )
260
+
261
+ @action(detail=True, methods=["PUT"], permission_classes=[IsPortfolioManager])
262
+ def changeexecutioninstruction(self, request, pk=None, order_proposal_id=None, **kwargs):
263
+ serializer = ExecutionInstructionSerializer(data=request.data)
264
+ order_proposal = get_object_or_404(OrderProposal, pk=order_proposal_id)
265
+ if serializer.is_valid(raise_exception=True):
266
+ parameters = dict(serializer.data)
267
+ orders_to_update = order_proposal.orders.all()
268
+ execution_instruction = parameters.pop("execution_instruction")
269
+ apply_execution_instruction_to_all_orders = parameters.pop("apply_execution_instruction_to_all_orders")
270
+ execution_parameters = {k: v for k, v in parameters.items() if v}
271
+ if not apply_execution_instruction_to_all_orders:
272
+ orders_to_update = orders_to_update.filter(id=pk)
273
+ orders_to_update.update(
274
+ execution_instruction=execution_instruction, execution_instruction_parameters=execution_parameters
275
+ )
276
+
277
+ return Response({"send": True})
@@ -2,6 +2,7 @@ from contextlib import suppress
2
2
  from datetime import date, datetime
3
3
 
4
4
  import pandas as pd
5
+ from celery import chain
5
6
  from django.db.models import OuterRef, Q, Subquery, Sum
6
7
  from django.http import HttpResponse
7
8
  from django.shortcuts import get_object_or_404
@@ -14,6 +15,7 @@ from rest_framework.reverse import reverse
14
15
  from wbcore import viewsets
15
16
  from wbcore.contrib.currency.models import Currency
16
17
  from wbcore.contrib.io.viewsets import ExportPandasAPIViewSet
18
+ from wbcore.contrib.notifications.dispatch import send_notification_as_task
17
19
  from wbcore.pandas import fields as pf
18
20
  from wbcore.permissions.permissions import InternalUserPermissionMixin
19
21
  from wbfdm.models import Instrument
@@ -35,6 +37,8 @@ from wbportfolio.serializers import (
35
37
  )
36
38
 
37
39
  from ..models.graphs.portfolio import PortfolioGraph
40
+ from ..models.utils import adjust_quote_as_task
41
+ from ..permissions import IsPortfolioManager
38
42
  from .configs import (
39
43
  PortfolioButtonConfig,
40
44
  PortfolioDisplayConfig,
@@ -178,6 +182,26 @@ class PortfolioModelViewSet(UserPortfolioRequestPermissionMixin, InternalUserPer
178
182
 
179
183
  return HttpResponse("Bad arguments", status=400)
180
184
 
185
+ @action(detail=False, methods=["POST"], permission_classes=[IsPortfolioManager])
186
+ def adjustquote(self, request, pk=None):
187
+ old_quote = get_object_or_404(Instrument, pk=request.POST["old_quote"])
188
+ new_quote = get_object_or_404(Instrument, pk=request.POST["new_quote"])
189
+ adjust_after = parse_date(request.data["adjust_after"]) if "adjust_after" in request.data else None
190
+ only_portfolio_ids = request.data["only_portfolios"].split(",") if "only_portfolios" in request.data else []
191
+
192
+ chain(
193
+ adjust_quote_as_task.si(
194
+ old_quote.id, new_quote.id, adjust_after=adjust_after, only_portfolio_ids=only_portfolio_ids
195
+ ),
196
+ send_notification_as_task.si(
197
+ "wbportfolio.portfolio.action_done",
198
+ f"Quote adjustment from {old_quote} to {new_quote} is done",
199
+ "The associated positions and orders were successfully adjusted",
200
+ request.user.id,
201
+ ),
202
+ ).apply_async()
203
+ return HttpResponse("Ok", status=200)
204
+
181
205
 
182
206
  class PortfolioPortfolioThroughModelViewSet(InternalUserPermissionMixin, viewsets.ModelViewSet):
183
207
  serializer_class = PortfolioPortfolioThroughModelSerializer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbportfolio
3
- Version: 1.55.8
3
+ Version: 1.59.4
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  License-File: LICENSE
6
6
  Requires-Dist: cryptography==3.4.*