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
@@ -3,25 +3,30 @@ import math
3
3
  from contextlib import suppress
4
4
  from datetime import date, timedelta
5
5
  from decimal import Decimal
6
- from typing import Any, TypeVar
6
+ from typing import Any, Self, TypeVar
7
7
 
8
+ import pandas as pd
8
9
  from celery import shared_task
9
10
  from django.core.exceptions import ValidationError
11
+ from django.core.validators import MaxValueValidator, MinValueValidator
10
12
  from django.db import DatabaseError, models
11
13
  from django.db.models import (
12
14
  F,
13
15
  OuterRef,
16
+ Q,
17
+ QuerySet,
14
18
  Subquery,
15
19
  Sum,
16
20
  Value,
17
21
  )
18
22
  from django.db.models.functions import Coalesce, Round
19
- from django.db.models.signals import post_save
23
+ from django.db.models.signals import post_save, pre_delete
20
24
  from django.dispatch import receiver
21
25
  from django.utils.functional import cached_property
22
- from django.utils.translation import gettext_lazy as _
26
+ from django.utils.translation import gettext_lazy
23
27
  from django_fsm import FSMField, transition
24
28
  from pandas._libs.tslibs.offsets import BDay
29
+ from requests import HTTPError
25
30
  from wbcompliance.models.risk_management.mixins import RiskCheckMixin
26
31
  from wbcore.contrib.authentication.models import User
27
32
  from wbcore.contrib.icons import WBIcon
@@ -31,20 +36,20 @@ from wbcore.enums import RequestType
31
36
  from wbcore.metadata.configs.buttons import ActionButton
32
37
  from wbcore.models import WBModel
33
38
  from wbcore.utils.models import CloneMixin
39
+ from wbfdm.enums import MarketData
34
40
  from wbfdm.models import InstrumentPrice
35
41
  from wbfdm.models.instruments.instruments import Cash, Instrument
42
+ from wbfdm.signals import investable_universe_updated
36
43
 
37
44
  from wbportfolio.models.asset import AssetPosition
38
45
  from wbportfolio.models.roles import PortfolioRole
39
- from wbportfolio.pms.trading import TradingService
40
46
  from wbportfolio.pms.typing import Order as OrderDTO
41
47
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
42
- from wbportfolio.pms.typing import Position as PositionDTO
43
48
 
44
49
  from ...order_routing import ExecutionStatus, RoutingException
45
- from ...order_routing.adapters import BaseCustodianAdapter
50
+ from ...order_routing.router import Router
51
+ from .. import Portfolio
46
52
  from .orders import Order
47
- from .routing import cancel_rebalancing, execute_orders, get_execution_status
48
53
 
49
54
  logger = logging.getLogger("pms")
50
55
 
@@ -59,8 +64,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
59
64
  PENDING = "PENDING", "Pending"
60
65
  APPROVED = "APPROVED", "Approved"
61
66
  DENIED = "DENIED", "Denied"
62
- APPLIED = "APPLIED", "Applied"
63
67
  EXECUTION = "EXECUTION", "Execution"
68
+ CONFIRMED = "CONFIRMED", "Confirmed"
64
69
  FAILED = "FAILED", "Failed"
65
70
 
66
71
  comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
@@ -96,12 +101,22 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
96
101
  min_order_value = models.IntegerField(
97
102
  default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
98
103
  )
104
+ min_weighting = models.DecimalField(
105
+ max_digits=9,
106
+ decimal_places=Order.ORDER_WEIGHTING_PRECISION,
107
+ default=Decimal(0),
108
+ help_text="The minimum weight allowed for this order proposal ",
109
+ verbose_name="Minimum Weight",
110
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
111
+ )
112
+
99
113
  total_cash_weight = models.DecimalField(
100
114
  default=Decimal("0"),
101
115
  decimal_places=4,
102
116
  max_digits=5,
103
117
  verbose_name="Total Cash Weight",
104
118
  help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
119
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
105
120
  )
106
121
  total_effective_portfolio_contribution = models.DecimalField(
107
122
  default=Decimal("1"),
@@ -135,7 +150,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
135
150
  )
136
151
  ]
137
152
 
153
+ def __str__(self) -> str:
154
+ return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
155
+
138
156
  def save(self, *args, **kwargs):
157
+ # if the order proposal is created, we default these fields with the portfolio default value for automatic value assignement
158
+ if not self.id and not self.min_order_value:
159
+ self.min_order_value = self.portfolio.default_order_proposal_min_order_value
160
+ if not self.id and not self.min_weighting:
161
+ self.min_weighting = self.portfolio.default_order_proposal_min_weighting
162
+ if not self.id and not self.total_cash_weight:
163
+ self.total_cash_weight = self.portfolio.default_order_proposal_total_cash_weight
139
164
  # if a order proposal is created before the existing earliest order proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
140
165
  if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
141
166
  # we need to set the inception date as the first order proposal trade date (and thus, the first position date). We expect a NAV at 100 then
@@ -154,21 +179,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
154
179
 
155
180
  @cached_property
156
181
  def portfolio_total_asset_value(self) -> Decimal:
157
- return self.portfolio.get_total_asset_value(self.last_effective_date)
158
-
159
- @cached_property
160
- def validated_trading_service(self) -> TradingService:
161
- """
162
- This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
163
- """
164
- target_portfolio = self.convert_to_portfolio()
165
-
166
- return TradingService(
167
- self.trade_date,
168
- effective_portfolio=self._get_default_effective_portfolio(),
169
- target_portfolio=target_portfolio,
170
- total_target_weight=target_portfolio.total_weight,
171
- )
182
+ return self.get_portfolio_total_asset_value()
172
183
 
173
184
  @cached_property
174
185
  def last_effective_date(self) -> date:
@@ -177,10 +188,10 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
177
188
  except AssetPosition.DoesNotExist:
178
189
  return self.value_date
179
190
 
180
- @cached_property
181
- def custodian_adapter(self) -> BaseCustodianAdapter | None:
191
+ @property
192
+ def custodian_router(self) -> Router | None:
182
193
  try:
183
- return self.portfolio.get_authenticated_custodian_adapter(raise_exception=True)
194
+ return Router(self.portfolio.get_authenticated_custodian_adapter(raise_exception=True))
184
195
  except ValueError as e:
185
196
  logger.warning("Error while instantiating custodian adapter: %s", e)
186
197
 
@@ -191,7 +202,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
191
202
  @property
192
203
  def previous_order_proposal(self) -> SelfOrderProposal | None:
193
204
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
194
- trade_date__lt=self.trade_date, status=OrderProposal.Status.APPLIED
205
+ trade_date__lt=self.trade_date, status=OrderProposal.Status.CONFIRMED
195
206
  )
196
207
  if future_proposals.exists():
197
208
  return future_proposals.latest("trade_date")
@@ -200,7 +211,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
200
211
  @property
201
212
  def next_order_proposal(self) -> SelfOrderProposal | None:
202
213
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
203
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
214
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
204
215
  )
205
216
  if future_proposals.exists():
206
217
  return future_proposals.earliest("trade_date")
@@ -218,6 +229,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
218
229
  def total_expected_target_weight(self) -> Decimal:
219
230
  return self.total_effective_portfolio_weight - self.total_cash_weight
220
231
 
232
+ @property
233
+ def can_be_confirmed(self) -> bool:
234
+ return self.portfolio.can_be_rebalanced and self.status == self.Status.APPROVED
235
+
236
+ @property
237
+ def can_be_applied(self):
238
+ return not self.has_non_successful_checks and self.portfolio.is_manageable
239
+
221
240
  @cached_property
222
241
  def total_effective_portfolio_cash_weight(self) -> Decimal:
223
242
  return self.portfolio.assets.filter(
@@ -225,10 +244,24 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
225
244
  & (models.Q(underlying_quote__is_cash=True) | models.Q(underlying_quote__is_cash_equivalent=True))
226
245
  ).aggregate(Sum("weighting"))["weighting__sum"] or Decimal("0")
227
246
 
228
- @property
229
- def execution_instruction(self):
230
- # TODO make this dynamically configurable
231
- return "MARKET_ON_CLOSE"
247
+ def get_portfolio_total_asset_value(self):
248
+ return self.portfolio.get_total_asset_value(self.last_effective_date)
249
+ # return self.orders.annotate(
250
+ # effective_shares=Coalesce(
251
+ # Subquery(
252
+ # AssetPosition.objects.filter(
253
+ # underlying_quote=OuterRef("underlying_instrument"),
254
+ # date=self.last_effective_date,
255
+ # portfolio=self.portfolio,
256
+ # )
257
+ # .values("portfolio")
258
+ # .annotate(s=Sum("shares"))
259
+ # .values("s")[:1]
260
+ # ),
261
+ # Decimal(0),
262
+ # ),
263
+ # effective_total_value_fx_portfolio=F("effective_shares") * F("currency_fx_rate") * F("price"),
264
+ # ).aggregate(s=Sum("effective_total_value_fx_portfolio"))["s"] or Decimal(0.0)
232
265
 
233
266
  def get_orders(self):
234
267
  # TODO Issue here: the cash is subqueried on the portfolio, on portfolio such as the fund, there is multiple cash component, that we exclude in the orders (and use a unique cash position instead)
@@ -267,7 +300,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
267
300
  effective_weight=Round(
268
301
  models.Case(
269
302
  models.When(total_effective_portfolio_contribution=Value(Decimal("0")), then=Value(Decimal("0"))),
270
- default=F("contribution") / F("total_effective_portfolio_contribution"),
303
+ default=F("contribution") / F("total_effective_portfolio_contribution") - F("quantization_error"),
271
304
  ),
272
305
  precision=Order.ORDER_WEIGHTING_PRECISION,
273
306
  ),
@@ -288,26 +321,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
288
321
  target_shares=F("effective_shares") + F("shares"),
289
322
  )
290
323
 
291
- if total_estimated_effective_weight := orders.aggregate(s=models.Sum("effective_weight"))["s"]:
292
- with suppress(Order.DoesNotExist):
293
- largest_order = orders.latest("effective_weight")
294
- if quant_error := self.total_effective_portfolio_weight - total_estimated_effective_weight:
295
- orders = orders.annotate(
296
- effective_weight=models.Case(
297
- models.When(
298
- id=largest_order.id,
299
- then=models.F("effective_weight") + models.Value(Decimal(quant_error)),
300
- ),
301
- default=models.F("effective_weight"),
302
- ),
303
- target_weight=models.Case(
304
- models.When(
305
- id=largest_order.id,
306
- then=models.F("target_weight") + models.Value(Decimal(quant_error)),
307
- ),
308
- default=models.F("target_weight"),
309
- ),
310
- )
311
324
  return orders.annotate(
312
325
  has_warnings=models.Case(
313
326
  models.When(
@@ -317,153 +330,224 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
317
330
  ),
318
331
  )
319
332
 
320
- def prepare_orders_for_execution(self) -> list[OrderDTO]:
333
+ def get_trades_batch(self):
334
+ return self._get_default_effective_portfolio().get_orders(self.get_target_portfolio())
335
+
336
+ @property
337
+ def can_be_executed(self) -> bool:
338
+ return (
339
+ self.custodian_router is not None
340
+ and not self.has_non_successful_checks
341
+ and self.status == self.Status.APPROVED
342
+ and (not self.execution_status or self.execution_status == ExecutionStatus.CANCELLED)
343
+ )
344
+
345
+ def can_execute(self, user: User) -> bool:
346
+ return (not self.approver or user.is_superuser or user != self.approver.user_account) and self.can_be_executed
347
+
348
+ def prepare_orders_for_execution(self, prioritize_target_weight: bool = False) -> list[OrderDTO]:
349
+ """Prepares executable orders by filtering and converting them for submission.
350
+
351
+ Filters out cash instruments and orders with zero weighting and shares, then
352
+ creates OrderDTOs for those having valid instrument identifiers. Orders with
353
+ unsupported asset classes or missing identifiers are marked as ignored with comments.
354
+ Updates ignored orders in bulk.
355
+
356
+ Args:
357
+ prioritize_target_weight: If True, prioritize target weight over share quantities
358
+ when preparing order quantities.
359
+
360
+ Returns:
361
+ A list of OrderDTO objects ready for execution submission.
362
+ """
321
363
  executable_orders = []
364
+ updated_orders = []
365
+ self.orders.update(execution_status=Order.ExecutionStatus.IGNORED)
322
366
  for order in (
323
- self.get_orders().exclude(underlying_instrument__is_cash=True).select_related("underlying_instrument")
367
+ self.get_orders()
368
+ .exclude(models.Q(underlying_instrument__is_cash=True) | (models.Q(weighting=0) & models.Q(shares=0)))
369
+ .select_related("underlying_instrument")
324
370
  ):
325
371
  instrument = order.underlying_instrument
326
- # we support only the instrument type provided by the Order DTO class
327
372
  asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
373
+
328
374
  try:
329
375
  if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
376
+ quantity = {"target_weight": float(order.target_weight)}
377
+ if not prioritize_target_weight and order.shares:
378
+ quantity["shares"] = float(order.shares)
379
+ quantity["target_shares"] = (
380
+ float(order.target_shares) if order.target_shares is not None else None
381
+ )
382
+
330
383
  executable_orders.append(
331
384
  OrderDTO(
332
385
  id=order.id,
333
386
  asset_class=OrderDTO.AssetType[asset_class],
334
387
  weighting=float(order.weighting),
335
- target_weight=float(order.target_weight),
336
388
  trade_date=order.value_date,
337
- shares=float(order.shares) if order.shares is not None else None,
338
- target_shares=float(order.target_shares) if order.target_shares is not None else None,
339
389
  refinitiv_identifier_code=instrument.refinitiv_identifier_code,
340
- bloomberg_ticker=instrument.ticker,
390
+ bloomberg_ticker=instrument.bloomberg_ticker,
341
391
  sedol=instrument.sedol,
342
- execution_instruction=self.execution_instruction,
392
+ execution_instruction=order.execution_instruction,
393
+ execution_instruction_parameters=order.execution_instruction_parameters,
394
+ **quantity,
343
395
  )
344
396
  )
345
397
  else:
346
- order.execution_confirmed = False
398
+ order.execution_status = Order.ExecutionStatus.FAILED
347
399
  order.execution_comment = "Underlying instrument does not have a valid identifier."
348
- order.save()
400
+ updated_orders.append(order)
349
401
  except (AttributeError, KeyError):
350
- order.execution_confirmed = False
402
+ order.execution_status = Order.ExecutionStatus.FAILED
351
403
  order.execution_comment = f"Unsupported asset class {asset_class.title()}."
352
- order.save()
404
+ updated_orders.append(order)
353
405
 
406
+ Order.objects.bulk_update(updated_orders, ["execution_status", "execution_comment"])
354
407
  return executable_orders
355
408
 
356
- def __str__(self) -> str:
357
- return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
409
+ def handle_orders(self, orders: list[OrderDTO]):
410
+ """Updates order statuses based on confirmed execution results.
358
411
 
359
- def convert_to_portfolio(
360
- self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
361
- ) -> PortfolioDTO:
362
- """
363
- Converts the internal portfolio state and pending orders into a PortfolioDTO.
412
+ For each confirmed order, updates the corresponding database record with its
413
+ execution status, comment, and price when available. Orders not present in the
414
+ confirmation list are marked as failed.
364
415
 
365
416
  Args:
366
- use_effective: Use effective share quantities for positions if True.
367
- with_cash: Include a cash position in the result if True.
368
- use_desired_target_weight: Use desired target weights from orders if True.
369
-
370
- Returns:
371
- PortfolioDTO: Object that encapsulates all portfolio positions.
417
+ orders: List of confirmed order DTOs returned from the custodian router.
372
418
  """
373
- portfolio = {}
419
+ leftover_orders = self.orders.filter(underlying_instrument__is_cash=False).all()
420
+ # portfolio_value = self.portfolio_total_asset_value
374
421
 
375
- # 1. Gather all non-cash, positively weighted assets from the existing portfolio.
376
- for asset in self.portfolio.assets.filter(
377
- date=self.last_effective_date,
378
- # underlying_quote__is_cash=False,
379
- # underlying_quote__is_cash_equivalent=False,
380
- weighting__gt=0,
381
- ):
382
- portfolio[asset.underlying_quote] = {
383
- "shares": asset._shares,
384
- "weighting": asset.weighting,
385
- "delta_weight": Decimal("0"),
386
- "price": asset._price,
387
- "currency_fx_rate": asset._currency_fx_rate,
388
- }
422
+ for confirmed_order in orders:
423
+ with suppress(Order.DoesNotExist):
424
+ order = leftover_orders.get(id=confirmed_order.id)
425
+ order.execution_status = Order.ExecutionStatus.CONFIRMED
426
+ order.execution_comment = confirmed_order.comment
427
+ # if execution_price := confirmed_order.execution_price:
428
+ # order.price = round(Decimal(execution_price), 2)
429
+ # order.execution_status = Order.ExecutionStatus.EXECUTED
430
+ # if shares := confirmed_order.shares:
431
+ # order.set_shares(Decimal(shares), portfolio_value)
432
+ # elif weighting := confirmed_order.weighting:
433
+ # order.set_weighting(Decimal(weighting), portfolio_value)
434
+ order.save()
435
+ leftover_orders = leftover_orders.exclude(id=order.id)
389
436
 
390
- # 2. Add or update non-cash orders, possibly overriding weights.
391
- for order in self.get_orders().filter(
392
- underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
393
- ):
394
- if use_desired_target_weight and order.desired_target_weight is not None:
395
- delta_weight = Decimal("0")
396
- weighting = order.desired_target_weight
397
- else:
398
- delta_weight = order.weighting
399
- weighting = order._previous_weight
437
+ leftover_orders.delete()
438
+ self.refresh_cash_position()
400
439
 
401
- portfolio[order.underlying_instrument] = {
402
- "weighting": weighting,
403
- "delta_weight": delta_weight,
404
- "shares": order._target_shares if not use_effective else order._effective_shares,
405
- "price": order.price,
406
- "currency_fx_rate": order.currency_fx_rate,
407
- }
440
+ def execute_orders(self, prioritize_target_weight: bool = False):
441
+ """Submits prepared orders for execution via the custodian router and updates status.
408
442
 
409
- # 3. Prepare a mapping from instrument IDs to weights for analytic calculations.
410
- previous_weights = {instrument.id: float(info["weighting"]) for instrument, info in portfolio.items()}
443
+ Prepares orders based on the target weight priority, submits them for execution,
444
+ handles confirmed orders on success, and records execution status and comments.
445
+ Logs and marks the execution as failed if submission raises an error.
411
446
 
412
- # 4. Attempt to fetch analytic returns and portfolio contribution. Default on error.
447
+ Args:
448
+ prioritize_target_weight: Whether to prioritize target weights when preparing orders.
449
+ """
450
+ self.status = self.Status.EXECUTION
451
+ orders = self.prepare_orders_for_execution(prioritize_target_weight=prioritize_target_weight)
413
452
  try:
414
- last_returns, contribution = self.portfolio.get_analytic_portfolio(
415
- self.value_date, weights=previous_weights, use_dl=True
416
- ).get_contributions()
417
- last_returns = last_returns.to_dict()
418
- except ValueError:
419
- last_returns, contribution = {}, 1
453
+ confirmed_orders, (status, rebalancing_comment) = self.custodian_router.submit_rebalancing(orders)
454
+ self.handle_orders(confirmed_orders)
455
+ except (ValueError, RoutingException, HTTPError) as e:
456
+ logger.error(f"Could not execute orders proposal {self}: {e}")
457
+ status = ExecutionStatus.FAILED
458
+ rebalancing_comment = str(e)
459
+ self.execution_status = status
460
+ self.execution_comment = rebalancing_comment
461
+ self.save()
420
462
 
421
- positions = []
422
- total_weighting = Decimal("0")
463
+ def refresh_execution_status(self):
464
+ """Updates execution status from the custodian router and saves the model.
423
465
 
424
- # 5. Build PositionDTO objects for all instruments.
425
- for instrument, row in portfolio.items():
426
- weighting = row["weighting"]
427
- daily_return = Decimal(last_returns.get(instrument.id, 0))
466
+ Retrieves the latest rebalance status and details, assigns them to the instance,
467
+ and persists changes to the database.
468
+ """
469
+ self.execution_status, self.execution_status_detail = self.custodian_router.get_rebalance_status()
470
+ if self.execution_status == ExecutionStatus.COMPLETED:
471
+ self.execution_status = self.Status.CONFIRMED
472
+ self.save()
428
473
 
429
- # Optionally apply drift to weightings if required
430
- if not use_effective and not use_desired_target_weight:
431
- if contribution:
432
- drifted_weight = round(
433
- weighting * (daily_return + Decimal("1")) / Decimal(contribution),
434
- Order.ORDER_WEIGHTING_PRECISION,
435
- )
436
- else:
437
- drifted_weight = weighting
438
- weighting = drifted_weight + row["delta_weight"]
474
+ def cancel_rebalancing(self):
475
+ """Cancels the ongoing rebalance via the custodian router and updates the model.
439
476
 
440
- # Assemble the position object
441
- positions.append(
442
- PositionDTO(
443
- underlying_instrument=instrument.id,
444
- instrument_type=instrument.instrument_type.id,
445
- weighting=weighting,
446
- daily_return=daily_return if use_effective else Decimal("0"),
447
- shares=row["shares"],
448
- currency=instrument.currency.id,
449
- date=self.last_effective_date if use_effective else self.trade_date,
450
- is_cash=instrument.is_cash or instrument.is_cash_equivalent,
451
- price=row["price"],
452
- currency_fx_rate=row["currency_fx_rate"],
453
- )
477
+ If cancellation succeeds, clears execution details, marks the status as cancelled,
478
+ saves the instance, and returns the cancellation status.
479
+ """
480
+ cancel_rebalancing_status = self.custodian_router.cancel_rebalancing()
481
+ if cancel_rebalancing_status:
482
+ (
483
+ self.execution_comment,
484
+ self.execution_status_detail,
485
+ self.execution_status,
486
+ ) = (
487
+ "",
488
+ "",
489
+ ExecutionStatus.CANCELLED,
454
490
  )
455
- total_weighting += weighting
491
+ self.save()
492
+ return cancel_rebalancing_status
456
493
 
457
- # 6. Optionally include a cash position to balance the total weighting.
458
- if (
459
- portfolio
460
- and with_cash
461
- and total_weighting
462
- and self.total_effective_portfolio_weight
463
- and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
464
- ):
465
- cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
466
- positions.append(cash_position._build_dto())
494
+ def get_target_portfolio(self):
495
+ positions = []
496
+ instrument_ids = []
497
+ for order in self.get_orders():
498
+ pos = order.to_dto()
499
+ instrument_ids.append(pos.underlying_instrument)
500
+ positions.append(pos)
501
+
502
+ # insert latest market data
503
+ df = pd.DataFrame(
504
+ Instrument.objects.filter(id__in=instrument_ids).dl.market_data(
505
+ [MarketData.MARKET_CAPITALIZATION_CONSOLIDATED, MarketData.VOLUME, MarketData.CLOSE],
506
+ from_date=self.trade_date - timedelta(days=50),
507
+ to_date=self.trade_date,
508
+ target_currency="USD",
509
+ )
510
+ )
511
+ df["volume_50d"] = df["volume"]
512
+ df = (
513
+ df[
514
+ [
515
+ "valuation_date",
516
+ "instrument_id",
517
+ "volume",
518
+ "volume_50d",
519
+ "close",
520
+ "market_capitalization_consolidated",
521
+ ]
522
+ ]
523
+ .sort_values(by="valuation_date")
524
+ .groupby("instrument_id")
525
+ .agg(
526
+ {
527
+ "volume": "last",
528
+ "volume_50d": "mean",
529
+ "close": "last",
530
+ "market_capitalization_consolidated": "last",
531
+ }
532
+ )
533
+ )
534
+ df["volume_usd"] = df.volume * df.close
535
+
536
+ for pos in positions:
537
+ if pos.underlying_instrument in df.index:
538
+ pos.market_capitalization_usd = df.loc[pos.underlying_instrument, "market_capitalization_consolidated"]
539
+ pos.volume_usd = (
540
+ df.loc[pos.underlying_instrument, "volume"] * df.loc[pos.underlying_instrument, "close"]
541
+ )
542
+ if pos.shares:
543
+ if volume_50d := df.loc[pos.underlying_instrument, "volume_50d"]:
544
+ pos.daily_liquidity = float(pos.shares) / volume_50d / 0.33
545
+ if pos.market_capitalization_usd:
546
+ pos.market_share = (
547
+ float(pos.shares)
548
+ * df.loc[pos.underlying_instrument, "close"]
549
+ / pos.market_capitalization_usd
550
+ )
467
551
 
468
552
  return PortfolioDTO(positions)
469
553
 
@@ -497,35 +581,26 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
497
581
 
498
582
  return order_proposal_clone
499
583
 
500
- def normalize_orders(self):
584
+ def normalize_orders(self, total_cash_weight: Decimal):
501
585
  """
502
- Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
503
- The existing order will be modified directly with the given normalization factor
586
+ Normalize the orders to accomodate the given cash weight
504
587
  """
505
- service = TradingService(
506
- self.trade_date,
507
- effective_portfolio=self._get_default_effective_portfolio(),
508
- target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
509
- total_target_weight=self.total_expected_target_weight,
510
- )
511
- leftovers_orders = self.orders.all()
512
- for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
513
- with suppress(Order.DoesNotExist):
514
- order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
515
- order.weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
516
- order.save()
517
- leftovers_orders = leftovers_orders.exclude(id=order.id)
518
- leftovers_orders.delete()
519
- self.fix_quantization()
588
+ self.total_cash_weight = total_cash_weight
589
+ self.reset_orders()
520
590
 
521
591
  def fix_quantization(self):
522
592
  if self.orders.exists():
523
593
  orders = self.get_orders()
524
- t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
594
+ t_weight = orders.aggregate(models.Sum("effective_weight"))["effective_weight__sum"] or Decimal("0.0")
595
+ quantization_error = orders.aggregate(models.Sum("quantization_error"))[
596
+ "quantization_error__sum"
597
+ ] or Decimal("0.0")
525
598
  # we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
526
- if quantize_error := (t_weight - self.total_expected_target_weight):
527
- biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
528
- biggest_order.weighting -= quantize_error
599
+ if t_weight and (
600
+ quantize_error := ((t_weight + quantization_error) - self.total_effective_portfolio_weight)
601
+ ):
602
+ biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("effective_weight")
603
+ biggest_order.quantization_error = quantize_error
529
604
  biggest_order.save()
530
605
 
531
606
  def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
@@ -537,16 +612,93 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
537
612
  return self.rebalancing_model.get_target_portfolio(
538
613
  self.portfolio, self.trade_date, self.value_date, **params
539
614
  )
540
- return self.convert_to_portfolio(use_effective=False, use_desired_target_weight=use_desired_target_weight)
615
+ return self._get_default_effective_portfolio(
616
+ include_delta_weight=True, use_desired_target_weight=use_desired_target_weight
617
+ )
541
618
 
542
- def _get_default_effective_portfolio(self):
543
- return self.convert_to_portfolio(use_effective=True)
619
+ def _get_default_effective_portfolio(
620
+ self, include_delta_weight: bool = False, use_desired_target_weight: bool = False
621
+ ):
622
+ """
623
+ Converts the internal portfolio state and pending orders into a PortfolioDTO.
624
+
625
+ Returns:
626
+ PortfolioDTO: Object that encapsulates all portfolio positions.
627
+ """
628
+ portfolio = {}
629
+
630
+ try:
631
+ analytic_portfolio = self.portfolio.get_analytic_portfolio(self.last_effective_date, use_dl=True)
632
+ last_returns, contribution = analytic_portfolio.get_contributions()
633
+ last_returns = last_returns.to_dict()
634
+ effective_weights = analytic_portfolio.get_next_weights()
635
+ except ValueError:
636
+ effective_weights, last_returns, contribution = {}, {}, 1
637
+ self.total_effective_portfolio_contribution = Decimal(contribution)
638
+ # 1. Gather all non-cash, positively weighted assets from the existing portfolio.
639
+ for asset in self.portfolio.assets.filter(
640
+ date=self.last_effective_date,
641
+ weighting__gt=0,
642
+ ):
643
+ portfolio[asset.underlying_quote] = {
644
+ "shares": asset._shares,
645
+ "weighting": Decimal(effective_weights.get(asset.underlying_quote.id, asset.weighting))
646
+ if not use_desired_target_weight
647
+ else Decimal("0"),
648
+ "price": asset._price,
649
+ "currency_fx_rate": asset._currency_fx_rate,
650
+ }
651
+
652
+ # 2. Add or update non-cash orders, possibly overriding weights.
653
+ for order in self.get_orders().filter(
654
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
655
+ ):
656
+ order.daily_return = last_returns.get(order.underlying_instrument.id, 0)
657
+ if use_desired_target_weight and order.desired_target_weight:
658
+ weighting = order.desired_target_weight
659
+ else:
660
+ weighting = order._effective_weight
661
+ if include_delta_weight:
662
+ weighting += order.weighting
663
+ portfolio[order.underlying_instrument] = {
664
+ "weighting": weighting,
665
+ "shares": order._effective_shares,
666
+ "price": order.price,
667
+ "currency_fx_rate": order.currency_fx_rate,
668
+ }
669
+ positions = []
670
+
671
+ # 5. Build PositionDTO objects for all instruments.
672
+ for instrument, row in portfolio.items():
673
+ daily_return = Decimal(last_returns.get(instrument.id, 0))
674
+ # Assemble the position object
675
+ pos = Order.create_dto(
676
+ instrument,
677
+ row["weighting"],
678
+ row["price"],
679
+ self.last_effective_date,
680
+ shares=row["shares"],
681
+ daily_return=daily_return,
682
+ currency_fx_rate=row["currency_fx_rate"],
683
+ )
684
+ positions.append(pos)
685
+ total_weighting = sum(map(lambda pos: pos.weighting, positions))
686
+ # 6. Optionally include a cash position to balance the total weighting.
687
+ if (
688
+ portfolio
689
+ and total_weighting
690
+ and self.total_effective_portfolio_weight
691
+ and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
692
+ ):
693
+ cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
694
+ positions.append(cash_position._build_dto())
695
+ return PortfolioDTO(positions)
544
696
 
545
697
  def reset_orders(
546
698
  self,
699
+ effective_portfolio: PortfolioDTO
700
+ | None = None, # we need to have this parameter as sometime we want to get the effective portfolio from drifted weight (unsaved)
547
701
  target_portfolio: PortfolioDTO | None = None,
548
- effective_portfolio: PortfolioRole | None = None,
549
- validate_order: bool = True,
550
702
  use_desired_target_weight: bool = False,
551
703
  ):
552
704
  """
@@ -556,38 +708,26 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
556
708
  self.orders.all().delete()
557
709
  else:
558
710
  self.orders.filter(underlying_instrument__is_cash=True).delete()
711
+ self.orders.update(quantization_error=0)
559
712
  # delete all existing orders
560
713
  # Get effective and target portfolio
561
- if not target_portfolio:
562
- target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
563
714
  if not effective_portfolio:
564
715
  effective_portfolio = self._get_default_effective_portfolio()
565
- if target_portfolio:
566
- service = TradingService(
567
- self.trade_date,
568
- effective_portfolio=effective_portfolio,
569
- target_portfolio=target_portfolio,
570
- total_target_weight=self.total_expected_target_weight,
571
- )
572
- if validate_order:
573
- service.is_valid()
574
- orders = service.validated_trades
575
- else:
576
- orders = service.trades_batch.trades_map.values()
716
+ if not target_portfolio:
717
+ target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
577
718
 
719
+ if self.total_cash_weight:
720
+ target_portfolio = target_portfolio.normalize_cash(self.total_cash_weight)
721
+ if target_portfolio:
578
722
  objs = []
579
- for order_dto in orders:
723
+ portfolio_value = self.portfolio_total_asset_value
724
+ for order_dto in effective_portfolio.get_orders(target_portfolio):
580
725
  instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
581
- currency_fx_rate = instrument.currency.convert(
582
- self.value_date, self.portfolio.currency, exact_lookup=True
583
- )
584
726
  # we cannot do a bulk-create because Order is a multi table inheritance
585
727
  weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
586
728
  daily_return = order_dto.daily_return
587
729
  try:
588
730
  order = self.orders.get(underlying_instrument=instrument)
589
- order.weighting = weighting
590
- order.currency_fx_rate = currency_fx_rate
591
731
  order.daily_return = daily_return
592
732
  except Order.DoesNotExist:
593
733
  order = Order(
@@ -596,17 +736,21 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
596
736
  value_date=self.trade_date,
597
737
  weighting=weighting,
598
738
  daily_return=daily_return,
599
- currency_fx_rate=currency_fx_rate,
600
739
  )
601
- order.desired_target_weight = order_dto.target_weight
602
740
  order.order_type = Order.get_type(
603
- weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
741
+ weighting, round(order_dto.effective_weight, 8), round(order_dto.target_weight, 8)
604
742
  )
743
+ order.quantization_error = order_dto.effective_quantization_error
744
+ if order_dto.price:
745
+ order.price = order_dto.price
605
746
  order.pre_save()
606
- # # if we cannot automatically find a price, we consider the stock is invalid and we sell it
607
- # if not order.price and order.weighting > 0:
608
- # order.price = Decimal("0.0")
609
- # order.weighting = -order_dto.effective_weight
747
+ order.set_weighting(weighting, portfolio_value)
748
+ order.desired_target_weight = order_dto.target_weight
749
+
750
+ # if we cannot automatically find a price, we consider the stock is invalid and we sell it
751
+ if not order.price and order.weighting > 0:
752
+ order.price = Decimal("0.0")
753
+ order.weighting = -order_dto.effective_weight
610
754
  objs.append(order)
611
755
  Order.objects.bulk_create(
612
756
  objs,
@@ -620,7 +764,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
620
764
  "price",
621
765
  "price_gross",
622
766
  "desired_target_weight",
623
- # "shares"
767
+ "quantization_error",
768
+ "shares",
624
769
  ],
625
770
  unique_fields=["order_proposal", "underlying_instrument"],
626
771
  update_conflicts=True,
@@ -631,41 +776,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
631
776
  target_weight=0, effective_weight=0
632
777
  ).delete()
633
778
  self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
634
- # self.fix_quantization()
635
- self.total_effective_portfolio_contribution = effective_portfolio.portfolio_contribution
779
+ self.fix_quantization()
636
780
  self.save()
637
781
 
638
- def apply_workflow(
639
- self,
640
- apply_automatically: bool = True,
641
- silent_exception: bool = False,
642
- force_reset_order: bool = False,
643
- **reset_order_kwargs,
644
- ):
645
- # before, we need to save all positions in the builder first because effective weight depends on it
646
- self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
647
- if self.status == OrderProposal.Status.APPLIED:
648
- logger.info("Reverting order proposal ...")
649
- self.revert()
650
- if self.status == OrderProposal.Status.DRAFT:
651
- if (
652
- self.rebalancing_model or force_reset_order
653
- ): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
654
- logger.info("Resetting orders ...")
655
- try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
656
- self.reset_orders(**reset_order_kwargs)
657
- except (ValidationError, DatabaseError) as e:
658
- self.status = OrderProposal.Status.FAILED
659
- if not silent_exception:
660
- raise ValidationError(e)
661
- return
662
- logger.info("Submitting order proposal ...")
663
- self.submit()
664
- if self.status == OrderProposal.Status.PENDING:
665
- self.approve()
666
- if apply_automatically and self.portfolio.can_be_rebalanced:
667
- logger.info("Applying order proposal ...")
668
- self.apply(replay=False)
782
+ def refresh_cash_position(self):
783
+ self.total_cash_weight = self.total_effective_portfolio_weight - self.get_orders().filter(
784
+ underlying_instrument__is_cash=False
785
+ ).aggregate(s=Sum("target_weight"))["s"] or Decimal("0")
786
+ cash_order = None
787
+ try:
788
+ cash_order = Order.objects.get(order_proposal=self, underlying_instrument=self.cash_component)
789
+ except Order.DoesNotExist:
790
+ if self.total_cash_weight:
791
+ cash_order = Order.objects.create(
792
+ order_proposal=self, underlying_instrument=self.cash_component, weighting=Decimal("0")
793
+ )
794
+ if cash_order:
795
+ cash_order.weighting = self.total_cash_weight - cash_order._previous_weight
796
+ cash_order.save()
797
+
798
+ def refresh_returns(self):
799
+ weights = {
800
+ row[0]: float(row[1]) for row in self.get_orders().values_list("underlying_instrument", "previous_weight")
801
+ }
802
+ last_returns, contribution = self.portfolio.get_analytic_portfolio(
803
+ self.value_date, weights=weights, use_dl=True
804
+ ).get_contributions()
805
+ last_returns = last_returns.to_dict()
806
+ orders_to_update = []
807
+ for order in self.orders.all():
808
+ with suppress(KeyError):
809
+ order.price = self.portfolio.builder.prices[self.trade_date][order.underlying_instrument.id]
810
+ try:
811
+ order.daily_return = last_returns[order.underlying_instrument.id]
812
+ except KeyError:
813
+ order.daily_return = Decimal("1.0")
814
+ order.quantization_error = Decimal("0")
815
+ orders_to_update.append(order)
816
+ Order.objects.bulk_update(orders_to_update, ["daily_return", "price", "quantization_error"])
817
+ self.total_effective_portfolio_contribution = Decimal(contribution)
818
+ self.save()
819
+ # ensure that sell orders keep having target weight at zero (might happens when returns are refreshed expost)
820
+ for order in self.get_orders().filter(Q(order_type=Order.Type.SELL) & ~Q(weighting=-F("target_weight"))):
821
+ order.weighting = -order.effective_weight
822
+ order.save()
823
+
824
+ # At this point, user needs to manually modify the orders in order to account for ex-post change. I am not sure we should we quantization at that point. To be monitored
825
+ # self.fix_quantization()
669
826
 
670
827
  def replay(
671
828
  self,
@@ -677,7 +834,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
677
834
  last_order_proposal = self
678
835
  last_order_proposal_created = False
679
836
  self.portfolio.load_builder_returns((self.trade_date - BDay(3)).date(), date.today())
680
- while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPLIED:
837
+ while last_order_proposal and last_order_proposal.status == OrderProposal.Status.CONFIRMED:
681
838
  last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
682
839
  if not last_order_proposal_created:
683
840
  if reapply_order_proposal or last_order_proposal.rebalancing_model:
@@ -689,7 +846,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
689
846
  else:
690
847
  logger.info(f"Resetting order proposal {last_order_proposal}")
691
848
  last_order_proposal.reset_orders(**reset_order_kwargs)
692
- if last_order_proposal.status != OrderProposal.Status.APPLIED:
849
+ if last_order_proposal.status != OrderProposal.Status.CONFIRMED:
693
850
  break
694
851
  next_order_proposal = last_order_proposal.next_order_proposal
695
852
  if next_order_proposal:
@@ -738,7 +895,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
738
895
  trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
739
896
  ).delete()
740
897
  for future_order_proposal in self.portfolio.order_proposals.filter(
741
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
898
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
742
899
  ):
743
900
  future_order_proposal.revert()
744
901
  future_order_proposal.save()
@@ -772,7 +929,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
772
929
  price_fx_portfolio = quote_price * underlying_quote.currency.convert(
773
930
  self.trade_date, self.portfolio.currency, exact_lookup=False
774
931
  )
775
-
776
932
  # If the price is valid, calculate and return the estimated shares
777
933
  if price_fx_portfolio:
778
934
  return trade_total_value_fx_portfolio / price_fx_portfolio
@@ -840,8 +996,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
840
996
  is_estimated=False,
841
997
  )
842
998
 
843
- # Start FSM logics
844
-
999
+ # WORKFLOW METHODS
845
1000
  @transition(
846
1001
  field=status,
847
1002
  source=Status.DRAFT,
@@ -852,7 +1007,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
852
1007
  custom={
853
1008
  "_transition_button": ActionButton(
854
1009
  method=RequestType.PATCH,
855
- identifiers=("wbportfolio:orderproposal",),
1010
+ identifiers=("wbportfolio:order",),
856
1011
  icon=WBIcon.SEND.icon,
857
1012
  key="submit",
858
1013
  label="Submit",
@@ -861,51 +1016,28 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
861
1016
  )
862
1017
  },
863
1018
  )
864
- def submit(self, by=None, description=None, **kwargs):
1019
+ def submit(self, by=None, description=None, pretrade_check: bool = True, **kwargs):
865
1020
  orders = []
866
1021
  orders_validation_warnings = []
867
1022
  qs = self.get_orders()
868
- total_target_weight = Decimal("0")
869
1023
  for order in qs:
870
1024
  order_warnings = order.submit(
871
1025
  by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
872
1026
  )
1027
+
873
1028
  if order_warnings:
874
1029
  orders_validation_warnings.extend(order_warnings)
875
1030
  orders.append(order)
876
- total_target_weight += order._target_weight
877
1031
 
878
1032
  Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
879
-
880
- # If we estimate cash on this order proposal, we make sure to create the corresponding cash component
881
- estimated_cash_position = self.get_estimated_target_cash()
882
- target_portfolio = self.validated_trading_service.trades_batch.convert_to_portfolio(
883
- estimated_cash_position._build_dto()
884
- )
885
- self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
886
- self.total_cash_weight = Decimal("1") - total_target_weight
1033
+ if pretrade_check:
1034
+ self.evaluate_pretrade_checks()
1035
+ else:
1036
+ self.refresh_cash_position()
887
1037
  return orders_validation_warnings
888
1038
 
889
1039
  def can_submit(self):
890
1040
  errors = dict()
891
- errors_list = []
892
- service = self.validated_trading_service
893
- try:
894
- service.is_valid(ignore_error=True)
895
- # if service.trades_batch.total_abs_delta_weight == 0:
896
- # errors_list.append(
897
- # "There is no change detected in this order proposal. Please submit at last one valid order"
898
- # )
899
- if len(service.validated_trades) == 0:
900
- errors_list.append(_("There is no valid order on this proposal"))
901
- if service.errors:
902
- errors_list.extend(service.errors)
903
- if errors_list:
904
- errors["non_field_errors"] = errors_list
905
- except ValidationError:
906
- errors["non_field_errors"] = service.errors
907
- with suppress(KeyError):
908
- del self.__dict__["validated_trading_service"]
909
1041
  return errors
910
1042
 
911
1043
  @transition(
@@ -919,7 +1051,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
919
1051
  custom={
920
1052
  "_transition_button": ActionButton(
921
1053
  method=RequestType.PATCH,
922
- identifiers=("wbportfolio:orderproposal",),
1054
+ identifiers=("wbportfolio:order",),
923
1055
  icon=WBIcon.APPROVE.icon,
924
1056
  key="approve",
925
1057
  label="Approve",
@@ -928,14 +1060,70 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
928
1060
  )
929
1061
  },
930
1062
  )
931
- def approve(self, by=None, description=None, **kwargs):
1063
+ def approve(self, by=None, replay: bool = True, **kwargs):
932
1064
  if by:
933
1065
  self.approver = getattr(by, "profile", None)
934
1066
  elif not self.approver:
935
1067
  self.approver = self.creator
1068
+ if self.portfolio.can_be_rebalanced:
1069
+ self.apply()
1070
+ if replay:
1071
+ replay_as_task.apply_async(
1072
+ (self.id,),
1073
+ {
1074
+ "user_id": by.id if by else None,
1075
+ "broadcast_changes_at_date": False,
1076
+ "reapply_order_proposal": True,
1077
+ },
1078
+ countdown=10,
1079
+ )
1080
+ if by and self.custodian_router:
1081
+ for user in User.objects.exclude(id=by.id).filter(
1082
+ profile__in=PortfolioRole.portfolio_managers(), is_active=True
1083
+ ):
1084
+ send_notification(
1085
+ code="wbportfolio.portfolio.action_done",
1086
+ title="An Order Proposal was approved and is waiting execution",
1087
+ body=f"The order proposal {self} has been approved by {by.profile.full_name} and is now pending execution. Please review the orders carefully and proceed with execution if appropriate.",
1088
+ user=user,
1089
+ reverse_name="wbportfolio:orderproposal-detail",
1090
+ reverse_args=[self.id],
1091
+ )
936
1092
 
937
- def can_approve(self):
938
- pass
1093
+ @transition(
1094
+ field=status,
1095
+ source=Status.APPROVED,
1096
+ target=Status.CONFIRMED,
1097
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1098
+ user.profile, portfolio=instance.portfolio
1099
+ )
1100
+ and instance.portfolio.can_be_rebalanced,
1101
+ custom={
1102
+ "_transition_button": ActionButton(
1103
+ method=RequestType.PATCH,
1104
+ identifiers=("wbportfolio:order",),
1105
+ icon=WBIcon.LOCK.icon,
1106
+ key="confirm",
1107
+ label="Confirm",
1108
+ action_label="Lock order proposal",
1109
+ # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
1110
+ )
1111
+ },
1112
+ )
1113
+ def confirm(self, by=None, replay: bool = True, **kwargs):
1114
+ self.refresh_cash_position()
1115
+ if self.portfolio.can_be_rebalanced:
1116
+ self.apply()
1117
+ if replay:
1118
+ replay_as_task.apply_async(
1119
+ (self.id,),
1120
+ {
1121
+ "user_id": by.id if by else None,
1122
+ "broadcast_changes_at_date": False,
1123
+ "reapply_order_proposal": True,
1124
+ },
1125
+ countdown=10,
1126
+ )
939
1127
 
940
1128
  @transition(
941
1129
  field=status,
@@ -948,7 +1136,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
948
1136
  custom={
949
1137
  "_transition_button": ActionButton(
950
1138
  method=RequestType.PATCH,
951
- identifiers=("wbportfolio:orderproposal",),
1139
+ identifiers=("wbportfolio:order",),
952
1140
  icon=WBIcon.DENY.icon,
953
1141
  key="deny",
954
1142
  label="Deny",
@@ -963,85 +1151,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
963
1151
  def can_deny(self):
964
1152
  pass
965
1153
 
966
- @property
967
- def can_be_applied(self):
968
- return not self.has_non_successful_checks and self.portfolio.is_manageable
969
-
970
- @transition(
971
- field=status,
972
- source=Status.APPROVED,
973
- target=Status.APPLIED,
974
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
975
- user.profile, portfolio=instance.portfolio
976
- )
977
- and instance.can_be_applied,
978
- custom={
979
- "_transition_button": ActionButton(
980
- method=RequestType.PATCH,
981
- identifiers=("wbportfolio:orderproposal",),
982
- icon=WBIcon.SAVE.icon,
983
- key="apply",
984
- label="Apply",
985
- action_label="Apply",
986
- )
987
- },
988
- )
989
- def apply(self, by=None, description=None, replay: bool = True, **kwargs):
1154
+ def apply(self):
990
1155
  # We validate order which will create or update the initial asset positions
991
1156
  if not self.portfolio.can_be_rebalanced:
992
1157
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
993
- warnings = []
1158
+
994
1159
  # We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
995
- estimated_cash_position = self.get_estimated_target_cash()
996
- assets = {}
997
- for order in self.get_orders():
998
- with suppress(ValueError):
999
- # we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
1000
- if (
1001
- order.underlying_instrument != estimated_cash_position.underlying_quote
1002
- and order._target_weight > 0
1003
- ):
1004
- assets[order.underlying_instrument.id] = order._target_weight
1005
-
1006
- # if there is cash leftover, we create an extra asset position to hold the cash component
1007
- if estimated_cash_position.weighting > 0 and len(assets) > 0:
1008
- warnings.append(
1009
- f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
1010
- )
1011
- assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
1160
+ target_portfolio = self.get_target_portfolio()
1161
+ assets = {i: float(pos.weighting) for i, pos in target_portfolio.positions_map.items()}
1012
1162
  self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
1013
1163
  force_save=True, is_estimated=False, delete_leftovers=True
1014
1164
  )
1015
- if replay and self.portfolio.is_manageable:
1016
- replay_as_task.delay(
1017
- self.id, user_id=by.id if by else None, broadcast_changes_at_date=False, reapply_order_proposal=True
1018
- )
1019
- if by:
1020
- self.approver = by.profile
1021
- return warnings
1022
-
1023
- def can_apply(self):
1024
- errors = dict()
1025
- orders = self.get_orders()
1026
- if not self.portfolio.can_be_rebalanced:
1027
- errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
1028
- if not orders.exists():
1029
- errors["non_field_errors"] = [
1030
- _("At least one order needs to be submitted to be able to apply this proposal")
1031
- ]
1032
- if not self.portfolio.can_be_rebalanced:
1033
- errors["portfolio"] = [
1034
- [_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
1035
- ]
1036
- if self.has_non_successful_checks:
1037
- errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
1038
- if orders.filter(has_warnings=True).filter(
1039
- underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
1040
- ):
1041
- errors["non_field_errors"] = [
1042
- _("There is warning that needs to be addresses on the orders before approval.")
1043
- ]
1044
- return errors
1045
1165
 
1046
1166
  @transition(
1047
1167
  field=status,
@@ -1055,7 +1175,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1055
1175
  custom={
1056
1176
  "_transition_button": ActionButton(
1057
1177
  method=RequestType.PATCH,
1058
- identifiers=("wbportfolio:orderproposal",),
1178
+ identifiers=("wbportfolio:order",),
1059
1179
  icon=WBIcon.UNDO.icon,
1060
1180
  key="backtodraft",
1061
1181
  label="Back to Draft",
@@ -1065,8 +1185,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1065
1185
  },
1066
1186
  )
1067
1187
  def backtodraft(self, **kwargs):
1068
- with suppress(KeyError):
1069
- del self.__dict__["validated_trading_service"]
1070
1188
  self.checks.delete()
1071
1189
 
1072
1190
  def can_backtodraft(self):
@@ -1074,7 +1192,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1074
1192
 
1075
1193
  @transition(
1076
1194
  field=status,
1077
- source=Status.APPLIED,
1195
+ source=Status.CONFIRMED,
1078
1196
  target=Status.DRAFT,
1079
1197
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1080
1198
  user.profile, portfolio=instance.portfolio
@@ -1082,7 +1200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1082
1200
  custom={
1083
1201
  "_transition_button": ActionButton(
1084
1202
  method=RequestType.PATCH,
1085
- identifiers=("wbportfolio:orderproposal",),
1203
+ identifiers=("wbportfolio:order",),
1086
1204
  icon=WBIcon.REGENERATE.icon,
1087
1205
  key="revert",
1088
1206
  label="Revert",
@@ -1093,8 +1211,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1093
1211
  )
1094
1212
  def revert(self, **kwargs):
1095
1213
  self.approver = None
1096
- with suppress(KeyError):
1097
- del self.__dict__["validated_trading_service"]
1098
1214
  self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
1099
1215
  is_estimated=True
1100
1216
  ) # we delete the existing portfolio as it has been reverted
@@ -1103,87 +1219,45 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1103
1219
  errors = dict()
1104
1220
  if not self.portfolio.can_be_rebalanced:
1105
1221
  errors["portfolio"] = [
1106
- _("The portfolio needs to be a model portfolio in order to revert this order proposal manually")
1222
+ gettext_lazy(
1223
+ "The portfolio needs to be a model portfolio in order to revert this order proposal manually"
1224
+ )
1107
1225
  ]
1108
1226
  return errors
1109
1227
 
1110
- @transition(
1111
- field=status,
1112
- source=Status.APPROVED,
1113
- target=Status.EXECUTION,
1114
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1115
- user.profile, portfolio=instance.portfolio
1116
- )
1117
- and (user != instance.approver or user.is_superuser)
1118
- and instance.custodian_adapter
1119
- and not instance.has_non_successful_checks,
1120
- custom={
1121
- "_transition_button": ActionButton(
1122
- method=RequestType.PATCH,
1123
- identifiers=("wbportfolio:orderproposal",),
1124
- icon=WBIcon.DEAL_MONEY.icon,
1125
- key="execute",
1126
- label="Execute",
1127
- action_label="Execute",
1128
- description_fields="<p>Execute the orders through the setup custodian.</p>",
1129
- )
1130
- },
1131
- )
1132
- def execute(self, **kwargs):
1133
- self.execution_status = ExecutionStatus.PENDING
1134
- self.execution_comment = "Waiting for custodian confirmation"
1135
- execute_orders_as_task.delay(self.id)
1136
-
1137
- def can_execute(self):
1138
- if not self.custodian_adapter:
1139
- return {"portfolio": ["No custodian adapter"]}
1140
-
1141
- @transition(
1142
- field=status,
1143
- source=Status.EXECUTION,
1144
- target=Status.APPROVED,
1145
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1146
- user.profile, portfolio=instance.portfolio
1147
- )
1148
- and instance.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT],
1149
- custom={
1150
- "_transition_button": ActionButton(
1151
- method=RequestType.PATCH,
1152
- identifiers=("wbportfolio:orderproposal",),
1153
- icon=WBIcon.PREVIOUS.icon,
1154
- key="cancelexecution",
1155
- label="Cancel Execution",
1156
- action_label="Cancel Execution",
1157
- description_fields="<p>Cancel the current requested execution. Time sensitive operation.</p>",
1158
- )
1159
- },
1160
- )
1161
- def cancelexecution(self, **kwargs):
1162
- warning = ""
1163
- try:
1164
- if cancel_rebalancing(self):
1165
- self.execution_comment, self.execution_status_detail, self.execution_status = (
1166
- "",
1167
- "",
1168
- ExecutionStatus.CANCELLED,
1169
- )
1228
+ def apply_workflow(
1229
+ self,
1230
+ apply_automatically: bool = True,
1231
+ silent_exception: bool = False,
1232
+ force_reset_order: bool = False,
1233
+ **reset_order_kwargs,
1234
+ ):
1235
+ # before, we need to save all positions in the builder first because effective weight depends on it
1236
+ self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
1237
+ if self.status == OrderProposal.Status.CONFIRMED:
1238
+ logger.info("Reverting order proposal ...")
1239
+ self.revert()
1240
+ if self.status == OrderProposal.Status.DRAFT:
1241
+ if (
1242
+ self.rebalancing_model or force_reset_order
1243
+ ): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
1244
+ logger.info("Resetting orders ...")
1245
+ try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
1246
+ self.reset_orders(**reset_order_kwargs)
1247
+ except (ValidationError, DatabaseError) as e:
1248
+ self.status = OrderProposal.Status.FAILED
1249
+ if not silent_exception:
1250
+ raise ValidationError(e) from e
1251
+ return
1252
+ logger.info("Submitting order proposal ...")
1253
+ self.submit(pretrade_check=False)
1254
+ if apply_automatically:
1255
+ logger.info("Applying order proposal ...")
1256
+ if self.status == OrderProposal.Status.PENDING:
1257
+ self.approve(replay=False)
1170
1258
  else:
1171
- warning = "We could not cancel the rebalancing. It is probably already executed. Please refresh status or check with an administrator."
1172
- except (RoutingException, ValueError) as e:
1173
- warning = f"Could not cancel orders proposal {self}: {str(e)}"
1174
- logger.error(warning)
1175
- return warning
1176
-
1177
- def can_cancelexecution(self):
1178
- if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1179
- return {"execution_status": "Execution can only be cancelled if it is not already executed"}
1180
-
1181
- def update_execution_status(self):
1182
- try:
1183
- self.execution_status, self.execution_status_detail = get_execution_status(self)
1184
- self.save()
1185
- except (RoutingException, ValueError) as e:
1186
- logger.warning(f"Could not update rebalancing status: {str(e)}")
1259
+ self.apply()
1260
+ self.status = self.Status.CONFIRMED
1187
1261
 
1188
1262
  # End FSM logics
1189
1263
 
@@ -1203,6 +1277,55 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1203
1277
  def get_representation_label_key(cls) -> str:
1204
1278
  return "{{_portfolio.name}} ({{trade_date}})"
1205
1279
 
1280
+ @classmethod
1281
+ def build(
1282
+ cls,
1283
+ trade_date: date,
1284
+ portfolio,
1285
+ target_portfolio: PortfolioDTO,
1286
+ creator: User | None = None,
1287
+ approve_automatically: bool = True,
1288
+ ) -> Self:
1289
+ order_proposal, _ = OrderProposal.objects.update_or_create(
1290
+ portfolio=portfolio,
1291
+ trade_date=trade_date,
1292
+ defaults={"status": OrderProposal.Status.DRAFT, "creator": creator.profile if creator else None},
1293
+ )
1294
+ order_proposal.reset_orders(target_portfolio=target_portfolio)
1295
+ if approve_automatically:
1296
+ order_proposal.submit()
1297
+ order_proposal.approve(by=creator)
1298
+ if portfolio.can_be_rebalanced:
1299
+ order_proposal.apply()
1300
+ order_proposal.save()
1301
+ return order_proposal
1302
+
1303
+ def push_to_dependant_portfolios(
1304
+ self, only_portfolios: QuerySet[Portfolio] | None = None, **build_kwargs
1305
+ ) -> list[Self]:
1306
+ order_proposals = []
1307
+ for rel in self.portfolio.get_model_portfolio_relationships(self.trade_date):
1308
+ existing_order_proposal = OrderProposal.objects.filter(
1309
+ portfolio=rel.portfolio, trade_date=self.trade_date
1310
+ ).first()
1311
+ # we allow push only on existing draft order proposal
1312
+ dependency_portfolio = rel.dependency_portfolio
1313
+ if (
1314
+ (only_portfolios is None or rel.portfolio in only_portfolios)
1315
+ and (not existing_order_proposal or existing_order_proposal.status == OrderProposal.Status.DRAFT)
1316
+ and dependency_portfolio.assets.filter(date=self.trade_date).exists()
1317
+ ):
1318
+ target_portfolio = dependency_portfolio._build_dto(self.trade_date)
1319
+ order_proposals.append(
1320
+ OrderProposal.build(self.trade_date, rel.portfolio, target_portfolio, **build_kwargs)
1321
+ )
1322
+ return order_proposals
1323
+
1324
+ def evaluate_pretrade_checks(self, asynchronously: bool = True):
1325
+ self.checks.all().delete()
1326
+ self.refresh_cash_position()
1327
+ self.evaluate_active_rules(self.trade_date, self.get_target_portfolio(), asynchronously=asynchronously)
1328
+
1206
1329
 
1207
1330
  @receiver(post_save, sender="wbportfolio.OrderProposal")
1208
1331
  def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kwargs):
@@ -1212,73 +1335,80 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
1212
1335
  instance.invalidate_future_order_proposal()
1213
1336
 
1214
1337
 
1215
- @shared_task(queue="portfolio")
1338
+ @shared_task(queue="oms")
1216
1339
  def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
1217
1340
  order_proposal = OrderProposal.objects.get(id=order_proposal_id)
1218
1341
  order_proposal.replay(**kwargs)
1219
1342
  if user_id:
1343
+ body = f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.'
1220
1344
  user = User.objects.get(id=user_id)
1345
+ if order_proposal.portfolio.builder.excluded_positions:
1346
+ excluded_quotes = []
1347
+ for batch in order_proposal.portfolio.builder.excluded_positions.values():
1348
+ for pos in batch:
1349
+ excluded_quotes.append(pos.underlying_instrument)
1350
+ body += "<p><strong>Note</strong></p><p>While replaying and drifting the portfolio, we excluded the positions from the following quotes because of missing price</p> <ul>"
1351
+ for excluded_quote in set(excluded_quotes):
1352
+ body += f"<li>{excluded_quote}</li>"
1353
+ body += "</ul>"
1354
+ order_proposal.portfolio.builder.clear()
1221
1355
  send_notification(
1222
- code="wbportfolio.portfolio.replay_done",
1356
+ code="wbportfolio.portfolio.action_done",
1223
1357
  title="Order Proposal Replay Completed",
1224
- body=f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.',
1358
+ body=body,
1225
1359
  user=user,
1226
1360
  reverse_name="wbportfolio:portfolio-detail",
1227
1361
  reverse_args=[order_proposal.portfolio.id],
1228
1362
  )
1229
1363
 
1230
1364
 
1231
- @shared_task(queue="portfolio")
1232
- def execute_orders_as_task(order_proposal_id: int):
1365
+ @shared_task(queue="oms")
1366
+ def execute_orders_as_task(order_proposal_id: int, prioritize_target_weight: bool = False, **kwargs):
1233
1367
  order_proposal = OrderProposal.objects.get(id=order_proposal_id)
1234
- try:
1235
- status, comment = execute_orders(order_proposal)
1236
- except (ValueError, RoutingException) as e:
1237
- logger.error(f"Could not execute orders proposal {order_proposal}: {str(e)}")
1238
- status = ExecutionStatus.FAILED
1239
- comment = str(e)
1240
- order_proposal.execution_status = status
1241
- order_proposal.execution_comment = comment
1242
- order_proposal.save()
1243
-
1244
-
1245
- @shared_task(queue="portfolio")
1368
+ order_proposal.execute_orders()
1369
+
1370
+
1371
+ @shared_task(queue="oms")
1246
1372
  def push_model_change_as_task(
1247
1373
  model_order_proposal_id: int,
1248
- user_id: int,
1374
+ user_id: int | None = None,
1249
1375
  only_for_portfolio_ids: list[int] | None = None,
1250
1376
  approve_automatically: bool = False,
1251
1377
  ):
1252
1378
  # not happy with that but we will keep it for the MVP lifecycle
1253
1379
  model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
1254
- trade_date = model_order_proposal.trade_date
1255
- user = User.objects.get(id=user_id)
1256
- from wbportfolio.models.rebalancing import RebalancingModel
1380
+ user = User.objects.get(id=user_id) if user_id else None
1381
+ params = dict(approve_automatically=approve_automatically, creator=user)
1382
+ only_portfolios = None
1383
+ if only_for_portfolio_ids:
1384
+ only_portfolios = Portfolio.objects.filter(id__in=only_for_portfolio_ids)
1257
1385
 
1258
- model_rebalancing = RebalancingModel.objects.get(
1259
- class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
1260
- )
1386
+ order_proposals = model_order_proposal.push_to_dependant_portfolios(only_portfolios=only_portfolios, **params)
1261
1387
  product_html_list = "<ul>\n"
1262
- for rel in model_order_proposal.portfolio.get_model_portfolio_relationships(trade_date):
1263
- if not only_for_portfolio_ids or rel.portfolio.id in only_for_portfolio_ids:
1264
- order_proposal, _ = OrderProposal.objects.update_or_create(
1265
- portfolio=rel.portfolio, trade_date=trade_date, defaults={"rebalancing_model": model_rebalancing}
1266
- )
1267
- order_proposal.reset_orders()
1268
- product_html_list += f"<li>{rel.portfolio}</li>\n"
1269
- if approve_automatically:
1270
- order_proposal.submit()
1271
- order_proposal.approve(by=user)
1272
- order_proposal.save()
1388
+ for order_proposal in order_proposals:
1389
+ product_html_list += f"<li>{order_proposal.portfolio}</li>\n"
1390
+
1273
1391
  product_html_list += "</ul>"
1392
+ if user:
1393
+ send_notification(
1394
+ code="wbportfolio.order_proposal.push_model_changes",
1395
+ title="Portfolio Model changes are pushed to dependant portfolios",
1396
+ body=f"""
1397
+ <p>The latest updates to the portfolio model <strong>{model_order_proposal.portfolio}</strong> have been successfully applied to the associated portfolios, and corresponding orders have been created.</p>
1398
+ <p>To proceed with executing these orders, please review the following related portfolios: </p>
1399
+ {product_html_list}
1400
+ """,
1401
+ user=user,
1402
+ )
1274
1403
 
1275
- send_notification(
1276
- code="wbportfolio.order_proposal.push_model_changes",
1277
- title="Portfolio Model changes are pushed to dependant portfolios",
1278
- body=f"""
1279
- <p>The latest updates to the portfolio model <strong>{model_order_proposal.portfolio}</strong> have been successfully applied to the associated portfolios, and corresponding orders have been created.</p>
1280
- <p>To proceed with executing these orders, please review the following related portfolios: </p>
1281
- {product_html_list}
1282
- """,
1283
- user=user,
1284
- )
1404
+
1405
+ @receiver(investable_universe_updated, sender="wbfdm.Instrument")
1406
+ def update_exante_order_proposal_returns(*args, end_date: date | None = None, **kwargs):
1407
+ for op in OrderProposal.objects.filter(trade_date__gte=end_date):
1408
+ op.refresh_returns()
1409
+
1410
+
1411
+ @receiver(pre_delete, sender=OrderProposal)
1412
+ def post_delete_adjustment(sender, instance: OrderProposal, **kwargs):
1413
+ for check in instance.checks.all():
1414
+ check.delete()