wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (62) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/portfolios.py +1 -1
  9. wbportfolio/factories/rebalancing.py +1 -1
  10. wbportfolio/filters/orders/__init__.py +1 -0
  11. wbportfolio/filters/orders/order_proposals.py +58 -0
  12. wbportfolio/filters/portfolios.py +20 -0
  13. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  14. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  15. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  16. wbportfolio/import_export/backends/utils.py +0 -17
  17. wbportfolio/import_export/handlers/asset_position.py +1 -1
  18. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  19. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  20. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  21. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  22. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  23. wbportfolio/models/builder.py +70 -25
  24. wbportfolio/models/mixins/instruments.py +7 -0
  25. wbportfolio/models/orders/order_proposals.py +510 -161
  26. wbportfolio/models/orders/orders.py +20 -10
  27. wbportfolio/models/orders/routing.py +54 -0
  28. wbportfolio/models/portfolio.py +76 -41
  29. wbportfolio/models/rebalancing.py +6 -6
  30. wbportfolio/models/transactions/transactions.py +10 -6
  31. wbportfolio/order_routing/__init__.py +19 -0
  32. wbportfolio/order_routing/adapters/__init__.py +57 -0
  33. wbportfolio/order_routing/adapters/ubs.py +161 -0
  34. wbportfolio/pms/trading/handler.py +4 -0
  35. wbportfolio/pms/typing.py +62 -8
  36. wbportfolio/rebalancing/models/composite.py +1 -1
  37. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  38. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  39. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  40. wbportfolio/serializers/orders/order_proposals.py +23 -3
  41. wbportfolio/serializers/orders/orders.py +5 -2
  42. wbportfolio/serializers/positions.py +2 -2
  43. wbportfolio/serializers/rebalancing.py +1 -1
  44. wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
  45. wbportfolio/tests/models/test_imports.py +5 -3
  46. wbportfolio/tests/models/test_portfolios.py +57 -23
  47. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  48. wbportfolio/tests/rebalancing/test_models.py +3 -5
  49. wbportfolio/viewsets/charts/assets.py +4 -1
  50. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  51. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  52. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  53. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
  54. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  55. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  56. wbportfolio/viewsets/orders/order_proposals.py +45 -6
  57. wbportfolio/viewsets/orders/orders.py +31 -29
  58. wbportfolio/viewsets/portfolios.py +3 -3
  59. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
  60. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
  61. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  62. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -26,6 +26,7 @@ from wbcompliance.models.risk_management.mixins import RiskCheckMixin
26
26
  from wbcore.contrib.authentication.models import User
27
27
  from wbcore.contrib.icons import WBIcon
28
28
  from wbcore.contrib.notifications.dispatch import send_notification
29
+ from wbcore.contrib.notifications.utils import create_notification_type
29
30
  from wbcore.enums import RequestType
30
31
  from wbcore.metadata.configs.buttons import ActionButton
31
32
  from wbcore.models import WBModel
@@ -36,10 +37,14 @@ from wbfdm.models.instruments.instruments import Cash, Instrument
36
37
  from wbportfolio.models.asset import AssetPosition
37
38
  from wbportfolio.models.roles import PortfolioRole
38
39
  from wbportfolio.pms.trading import TradingService
40
+ from wbportfolio.pms.typing import Order as OrderDTO
39
41
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
40
42
  from wbportfolio.pms.typing import Position as PositionDTO
41
43
 
44
+ from ...order_routing import ExecutionStatus, RoutingException
45
+ from ...order_routing.adapters import BaseCustodianAdapter
42
46
  from .orders import Order
47
+ from .routing import cancel_rebalancing, execute_orders, get_execution_status
43
48
 
44
49
  logger = logging.getLogger("pms")
45
50
 
@@ -51,9 +56,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
51
56
 
52
57
  class Status(models.TextChoices):
53
58
  DRAFT = "DRAFT", "Draft"
54
- SUBMIT = "SUBMIT", "Pending"
59
+ PENDING = "PENDING", "Pending"
55
60
  APPROVED = "APPROVED", "Approved"
56
61
  DENIED = "DENIED", "Denied"
62
+ APPLIED = "APPLIED", "Applied"
63
+ EXECUTION = "EXECUTION", "Execution"
57
64
  FAILED = "FAILED", "Failed"
58
65
 
59
66
  comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
@@ -78,6 +85,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
78
85
  on_delete=models.PROTECT,
79
86
  verbose_name="Owner",
80
87
  )
88
+ approver = models.ForeignKey(
89
+ "directory.Person",
90
+ blank=True,
91
+ null=True,
92
+ related_name="approver_order_proposals",
93
+ on_delete=models.PROTECT,
94
+ verbose_name="Approver",
95
+ )
81
96
  min_order_value = models.IntegerField(
82
97
  default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
83
98
  )
@@ -88,6 +103,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
88
103
  verbose_name="Total Cash Weight",
89
104
  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%.",
90
105
  )
106
+ total_effective_portfolio_contribution = models.DecimalField(
107
+ default=Decimal("1"),
108
+ max_digits=Order.ORDER_WEIGHTING_PRECISION * 2 + 3,
109
+ decimal_places=Order.ORDER_WEIGHTING_PRECISION * 2,
110
+ )
111
+ execution_status = models.CharField(
112
+ blank=True, default="", choices=ExecutionStatus.choices, verbose_name="Execution Status"
113
+ )
114
+ execution_status_detail = models.CharField(blank=True, default="", verbose_name="Execution Status Detail")
115
+ execution_comment = models.CharField(blank=True, default="", verbose_name="Execution Comment")
91
116
 
92
117
  class Meta:
93
118
  verbose_name = "Order Proposal"
@@ -99,6 +124,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
99
124
  ),
100
125
  ]
101
126
 
127
+ notification_types = [
128
+ create_notification_type(
129
+ "wbportfolio.order_proposal.push_model_changes",
130
+ "Push Model Changes",
131
+ "Sends a notification when a the change/orders are pushed to modeled after portfolios",
132
+ True,
133
+ True,
134
+ True,
135
+ )
136
+ ]
137
+
102
138
  def save(self, *args, **kwargs):
103
139
  # 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
104
140
  if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
@@ -141,6 +177,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
141
177
  except AssetPosition.DoesNotExist:
142
178
  return self.value_date
143
179
 
180
+ @cached_property
181
+ def custodian_adapter(self) -> BaseCustodianAdapter | None:
182
+ try:
183
+ return self.portfolio.get_authenticated_custodian_adapter(raise_exception=True)
184
+ except ValueError as e:
185
+ logger.warning("Error while instantiating custodian adapter: %s", e)
186
+
144
187
  @cached_property
145
188
  def value_date(self) -> date:
146
189
  return (self.trade_date - BDay(1)).date()
@@ -148,7 +191,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
148
191
  @property
149
192
  def previous_order_proposal(self) -> SelfOrderProposal | None:
150
193
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
151
- trade_date__lt=self.trade_date, status=OrderProposal.Status.APPROVED
194
+ trade_date__lt=self.trade_date, status=OrderProposal.Status.APPLIED
152
195
  )
153
196
  if future_proposals.exists():
154
197
  return future_proposals.latest("trade_date")
@@ -157,7 +200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
157
200
  @property
158
201
  def next_order_proposal(self) -> SelfOrderProposal | None:
159
202
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
160
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
203
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
161
204
  )
162
205
  if future_proposals.exists():
163
206
  return future_proposals.earliest("trade_date")
@@ -165,16 +208,34 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
165
208
 
166
209
  @property
167
210
  def cash_component(self) -> Cash:
168
- return Cash.objects.get_or_create(
169
- currency=self.portfolio.currency, defaults={"is_cash": True, "name": self.portfolio.currency.title}
170
- )[0]
211
+ return self.portfolio.cash_component
212
+
213
+ @property
214
+ def total_effective_portfolio_weight(self) -> Decimal:
215
+ return Decimal("1.0")
171
216
 
172
217
  @property
173
218
  def total_expected_target_weight(self) -> Decimal:
174
- return Decimal("1") - self.total_cash_weight
219
+ return self.total_effective_portfolio_weight - self.total_cash_weight
220
+
221
+ @cached_property
222
+ def total_effective_portfolio_cash_weight(self) -> Decimal:
223
+ return self.portfolio.assets.filter(
224
+ models.Q(date=self.last_effective_date)
225
+ & (models.Q(underlying_quote__is_cash=True) | models.Q(underlying_quote__is_cash_equivalent=True))
226
+ ).aggregate(Sum("weighting"))["weighting__sum"] or Decimal("0")
227
+
228
+ @property
229
+ def execution_instruction(self):
230
+ # TODO make this dynamically configurable
231
+ return "MARKET_ON_CLOSE"
175
232
 
176
233
  def get_orders(self):
177
- base_qs = self.orders.all().annotate(
234
+ # 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)
235
+ # so the subquery returns the previous position (probably USD), but is missing the other cash aggregation. We need to find a way to handle that properly
236
+
237
+ orders = self.orders.all().annotate(
238
+ total_effective_portfolio_contribution=Value(self.total_effective_portfolio_contribution),
178
239
  last_effective_date=Subquery(
179
240
  AssetPosition.unannotated_objects.filter(
180
241
  date__lt=OuterRef("value_date"),
@@ -183,27 +244,33 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
183
244
  .order_by("-date")
184
245
  .values("date")[:1]
185
246
  ),
186
- previous_weight=Coalesce(
187
- Subquery(
188
- AssetPosition.unannotated_objects.filter(
189
- underlying_quote=OuterRef("underlying_instrument"),
190
- date=OuterRef("last_effective_date"),
191
- portfolio=OuterRef("portfolio"),
192
- )
193
- .values("portfolio")
194
- .annotate(s=Sum("weighting"))
195
- .values("s")[:1]
247
+ previous_weight=models.Case(
248
+ models.When(
249
+ underlying_instrument__is_cash=False,
250
+ then=Coalesce(
251
+ Subquery(
252
+ AssetPosition.unannotated_objects.filter(
253
+ underlying_quote=OuterRef("underlying_instrument"),
254
+ date=OuterRef("last_effective_date"),
255
+ portfolio=OuterRef("portfolio"),
256
+ )
257
+ .values("portfolio")
258
+ .annotate(s=Sum("weighting"))
259
+ .values("s")[:1]
260
+ ),
261
+ Decimal(0),
262
+ ),
196
263
  ),
197
- Decimal(0),
264
+ default=Value(self.total_effective_portfolio_cash_weight),
198
265
  ),
199
266
  contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
200
- )
201
- portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
202
- orders = base_qs.annotate(
203
267
  effective_weight=Round(
204
- F("contribution") / Value(portfolio_contribution), precision=Order.ORDER_WEIGHTING_PRECISION
268
+ models.Case(
269
+ models.When(total_effective_portfolio_contribution=Value(Decimal("0")), then=Value(Decimal("0"))),
270
+ default=F("contribution") / F("total_effective_portfolio_contribution"),
271
+ ),
272
+ precision=Order.ORDER_WEIGHTING_PRECISION,
205
273
  ),
206
- tmp_effective_weight=F("contribution") / Value(portfolio_contribution),
207
274
  target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
208
275
  effective_shares=Coalesce(
209
276
  Subquery(
@@ -220,30 +287,70 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
220
287
  ),
221
288
  target_shares=F("effective_shares") + F("shares"),
222
289
  )
223
- total_effective_weight = orders.aggregate(s=models.Sum("effective_weight"))["s"] or Decimal("1")
224
- with suppress(Order.DoesNotExist):
225
- largest_order = orders.latest("effective_weight")
226
- if quant_error := Decimal("1") - total_effective_weight:
227
- orders = orders.annotate(
228
- effective_weight=models.Case(
229
- models.When(
230
- id=largest_order.id, then=models.F("effective_weight") + models.Value(Decimal(quant_error))
290
+
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"),
231
302
  ),
232
- default=models.F("effective_weight"),
233
- ),
234
- target_weight=models.Case(
235
- models.When(
236
- id=largest_order.id, then=models.F("target_weight") + models.Value(Decimal(quant_error))
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"),
237
309
  ),
238
- default=models.F("target_weight"),
239
- ),
240
- )
310
+ )
241
311
  return orders.annotate(
242
312
  has_warnings=models.Case(
243
- models.When(models.Q(price=0) | models.Q(target_weight__lt=0), then=Value(True)), default=Value(False)
313
+ models.When(
314
+ (models.Q(price=0) & ~models.Q(target_weight=0)) | models.Q(target_weight__lt=0), then=Value(True)
315
+ ),
316
+ default=Value(False),
244
317
  ),
245
318
  )
246
319
 
320
+ def prepare_orders_for_execution(self) -> list[OrderDTO]:
321
+ executable_orders = []
322
+ for order in self.get_orders().select_related("underlying_instrument"):
323
+ instrument = order.underlying_instrument
324
+ # we support only the instrument type provided by the Order DTO class
325
+ asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
326
+ try:
327
+ if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
328
+ executable_orders.append(
329
+ OrderDTO(
330
+ id=order.id,
331
+ asset_class=OrderDTO.AssetType[asset_class],
332
+ weighting=float(order.weighting),
333
+ target_weight=float(order.target_weight),
334
+ trade_date=order.value_date,
335
+ shares=float(order.shares) if order.shares is not None else None,
336
+ target_shares=float(order.target_shares) if order.target_shares is not None else None,
337
+ refinitiv_identifier_code=instrument.refinitiv_identifier_code,
338
+ bloomberg_ticker=instrument.ticker,
339
+ sedol=instrument.sedol,
340
+ execution_instruction=self.execution_instruction,
341
+ )
342
+ )
343
+ else:
344
+ order.execution_confirmed = False
345
+ order.execution_comment = "Underlying instrument does not have a valid identifier."
346
+ order.save()
347
+ except AttributeError:
348
+ order.execution_confirmed = False
349
+ order.execution_comment = f"Unsupported asset class {asset_class.title()}."
350
+ order.save()
351
+
352
+ return executable_orders
353
+
247
354
  def __str__(self) -> str:
248
355
  return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
249
356
 
@@ -251,56 +358,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
251
358
  self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
252
359
  ) -> PortfolioDTO:
253
360
  """
254
- Data Transfer Object
361
+ Converts the internal portfolio state and pending orders into a PortfolioDTO.
362
+
363
+ Args:
364
+ use_effective: Use effective share quantities for positions if True.
365
+ with_cash: Include a cash position in the result if True.
366
+ use_desired_target_weight: Use desired target weights from orders if True.
367
+
255
368
  Returns:
256
- DTO order object
369
+ PortfolioDTO: Object that encapsulates all portfolio positions.
257
370
  """
258
371
  portfolio = {}
259
- for asset in self.portfolio.assets.filter(date=self.last_effective_date):
260
- portfolio[asset.underlying_quote] = dict(
261
- shares=asset._shares,
262
- weighting=asset.weighting,
263
- delta_weight=Decimal("0"),
264
- price=asset._price,
265
- currency_fx_rate=asset._currency_fx_rate,
266
- )
267
- for order in self.get_orders():
268
- previous_weight = order._previous_weight
372
+
373
+ # 1. Gather all non-cash, positively weighted assets from the existing portfolio.
374
+ for asset in self.portfolio.assets.filter(
375
+ date=self.last_effective_date,
376
+ # underlying_quote__is_cash=False,
377
+ # underlying_quote__is_cash_equivalent=False,
378
+ weighting__gt=0,
379
+ ):
380
+ portfolio[asset.underlying_quote] = {
381
+ "shares": asset._shares,
382
+ "weighting": asset.weighting,
383
+ "delta_weight": Decimal("0"),
384
+ "price": asset._price,
385
+ "currency_fx_rate": asset._currency_fx_rate,
386
+ }
387
+
388
+ # 2. Add or update non-cash orders, possibly overriding weights.
389
+ for order in self.get_orders().filter(
390
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
391
+ ):
269
392
  if use_desired_target_weight and order.desired_target_weight is not None:
270
- delta_weight = order.desired_target_weight - previous_weight
393
+ delta_weight = Decimal("0")
394
+ weighting = order.desired_target_weight
271
395
  else:
272
396
  delta_weight = order.weighting
273
- portfolio[order.underlying_instrument] = dict(
274
- weighting=previous_weight,
275
- delta_weight=delta_weight,
276
- shares=order._target_shares if not use_effective else order._effective_shares,
277
- price=order.price,
278
- currency_fx_rate=order.currency_fx_rate,
279
- )
280
- previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
397
+ weighting = order._previous_weight
398
+
399
+ portfolio[order.underlying_instrument] = {
400
+ "weighting": weighting,
401
+ "delta_weight": delta_weight,
402
+ "shares": order._target_shares if not use_effective else order._effective_shares,
403
+ "price": order.price,
404
+ "currency_fx_rate": order.currency_fx_rate,
405
+ }
406
+
407
+ # 3. Prepare a mapping from instrument IDs to weights for analytic calculations.
408
+ previous_weights = {instrument.id: float(info["weighting"]) for instrument, info in portfolio.items()}
409
+
410
+ # 4. Attempt to fetch analytic returns and portfolio contribution. Default on error.
281
411
  try:
282
- last_returns, portfolio_contribution = self.portfolio.get_analytic_portfolio(
412
+ last_returns, contribution = self.portfolio.get_analytic_portfolio(
283
413
  self.value_date, weights=previous_weights, use_dl=True
284
414
  ).get_contributions()
285
415
  last_returns = last_returns.to_dict()
286
416
  except ValueError:
287
- last_returns, portfolio_contribution = {}, 1
417
+ last_returns, contribution = {}, 1
418
+
288
419
  positions = []
289
420
  total_weighting = Decimal("0")
421
+
422
+ # 5. Build PositionDTO objects for all instruments.
290
423
  for instrument, row in portfolio.items():
291
424
  weighting = row["weighting"]
292
425
  daily_return = Decimal(last_returns.get(instrument.id, 0))
293
426
 
294
- if not use_effective:
295
- drifted_weight = (
296
- round(
297
- weighting * (daily_return + Decimal("1")) / Decimal(portfolio_contribution),
427
+ # Optionally apply drift to weightings if required
428
+ if not use_effective and not use_desired_target_weight:
429
+ if contribution:
430
+ drifted_weight = round(
431
+ weighting * (daily_return + Decimal("1")) / Decimal(contribution),
298
432
  Order.ORDER_WEIGHTING_PRECISION,
299
433
  )
300
- if portfolio_contribution
301
- else weighting
302
- )
434
+ else:
435
+ drifted_weight = weighting
303
436
  weighting = drifted_weight + row["delta_weight"]
437
+
438
+ # Assemble the position object
304
439
  positions.append(
305
440
  PositionDTO(
306
441
  underlying_instrument=instrument.id,
@@ -316,9 +451,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
316
451
  )
317
452
  )
318
453
  total_weighting += weighting
319
- if portfolio and with_cash and total_weighting and (cash_weight := Decimal("1") - total_weighting):
454
+
455
+ # 6. Optionally include a cash position to balance the total weighting.
456
+ if (
457
+ portfolio
458
+ and with_cash
459
+ and total_weighting
460
+ and self.total_effective_portfolio_weight
461
+ and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
462
+ ):
320
463
  cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
321
464
  positions.append(cash_position._build_dto())
465
+
322
466
  return PortfolioDTO(positions)
323
467
 
324
468
  # Start tools methods
@@ -374,10 +518,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
374
518
 
375
519
  def fix_quantization(self):
376
520
  if self.orders.exists():
377
- t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
521
+ orders = self.get_orders()
522
+ t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
378
523
  # 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
379
524
  if quantize_error := (t_weight - self.total_expected_target_weight):
380
- biggest_order = self.orders.latest("weighting")
525
+ biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
381
526
  biggest_order.weighting -= quantize_error
382
527
  biggest_order.save()
383
528
 
@@ -407,6 +552,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
407
552
  """
408
553
  if self.rebalancing_model:
409
554
  self.orders.all().delete()
555
+ else:
556
+ self.orders.filter(underlying_instrument__is_cash=True).delete()
410
557
  # delete all existing orders
411
558
  # Get effective and target portfolio
412
559
  if not target_portfolio:
@@ -426,6 +573,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
426
573
  else:
427
574
  orders = service.trades_batch.trades_map.values()
428
575
 
576
+ objs = []
429
577
  for order_dto in orders:
430
578
  instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
431
579
  currency_fx_rate = instrument.currency.convert(
@@ -448,30 +596,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
448
596
  daily_return=daily_return,
449
597
  currency_fx_rate=currency_fx_rate,
450
598
  )
451
- order.price = order.get_price()
452
- # if we cannot automatically find a price, we consider the stock is invalid and we sell it
453
- if not order.price:
454
- order.price = Decimal("0.0")
455
- order.weighting = -order_dto.effective_weight
456
-
457
- order.save()
599
+ order.desired_target_weight = order_dto.target_weight
600
+ order.order_type = Order.get_type(
601
+ weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
602
+ )
603
+ order.pre_save()
604
+ # # if we cannot automatically find a price, we consider the stock is invalid and we sell it
605
+ # if not order.price and order.weighting > 0:
606
+ # order.price = Decimal("0.0")
607
+ # order.weighting = -order_dto.effective_weight
608
+ objs.append(order)
609
+ Order.objects.bulk_create(
610
+ objs,
611
+ update_fields=[
612
+ "value_date",
613
+ "weighting",
614
+ "daily_return",
615
+ "currency_fx_rate",
616
+ "order_type",
617
+ "portfolio",
618
+ "price",
619
+ "price_gross",
620
+ "desired_target_weight",
621
+ # "shares"
622
+ ],
623
+ unique_fields=["order_proposal", "underlying_instrument"],
624
+ update_conflicts=True,
625
+ batch_size=1000,
626
+ )
458
627
  # final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
459
- self.get_orders().filter(target_weight=0, effective_weight=0).delete()
460
-
461
- self.fix_quantization()
462
-
463
- for order in self.get_orders():
464
- order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
465
- order.save()
628
+ self.get_orders().exclude(underlying_instrument__is_cash=True).filter(
629
+ target_weight=0, effective_weight=0
630
+ ).delete()
631
+ self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
632
+ # self.fix_quantization()
633
+ self.total_effective_portfolio_contribution = effective_portfolio.portfolio_contribution
634
+ self.save()
466
635
 
467
- def approve_workflow(
636
+ def apply_workflow(
468
637
  self,
469
- approve_automatically: bool = True,
638
+ apply_automatically: bool = True,
470
639
  silent_exception: bool = False,
471
640
  force_reset_order: bool = False,
472
641
  **reset_order_kwargs,
473
642
  ):
474
- if self.status == OrderProposal.Status.APPROVED:
643
+ # before, we need to save all positions in the builder first because effective weight depends on it
644
+ self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
645
+ if self.status == OrderProposal.Status.APPLIED:
475
646
  logger.info("Reverting order proposal ...")
476
647
  self.revert()
477
648
  if self.status == OrderProposal.Status.DRAFT:
@@ -488,26 +659,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
488
659
  return
489
660
  logger.info("Submitting order proposal ...")
490
661
  self.submit()
491
- if self.status == OrderProposal.Status.SUBMIT:
492
- logger.info("Approving order proposal ...")
493
- if approve_automatically and self.portfolio.can_be_rebalanced:
494
- self.approve(replay=False)
662
+ if self.status == OrderProposal.Status.PENDING:
663
+ self.approve()
664
+ if apply_automatically and self.portfolio.can_be_rebalanced:
665
+ logger.info("Applying order proposal ...")
666
+ self.apply(replay=False)
495
667
 
496
- def replay(self, broadcast_changes_at_date: bool = True, reapply_order_proposal: bool = False):
668
+ def replay(
669
+ self,
670
+ broadcast_changes_at_date: bool = True,
671
+ reapply_order_proposal: bool = False,
672
+ synchronous: bool = False,
673
+ **reset_order_kwargs,
674
+ ):
497
675
  last_order_proposal = self
498
676
  last_order_proposal_created = False
499
- self.portfolio.load_builder_returns(self.trade_date, date.today())
500
- while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPROVED:
677
+ self.portfolio.load_builder_returns((self.trade_date - BDay(3)).date(), date.today())
678
+ while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPLIED:
501
679
  last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
502
680
  if not last_order_proposal_created:
503
- if reapply_order_proposal:
681
+ if reapply_order_proposal or last_order_proposal.rebalancing_model:
504
682
  logger.info(f"Replaying order proposal {last_order_proposal}")
505
- last_order_proposal.approve_workflow(silent_exception=True, force_reset_order=True)
683
+ last_order_proposal.apply_workflow(
684
+ silent_exception=True, force_reset_order=True, **reset_order_kwargs
685
+ )
506
686
  last_order_proposal.save()
507
687
  else:
508
688
  logger.info(f"Resetting order proposal {last_order_proposal}")
509
- last_order_proposal.reset_orders()
510
- if last_order_proposal.status != OrderProposal.Status.APPROVED:
689
+ last_order_proposal.reset_orders(**reset_order_kwargs)
690
+ if last_order_proposal.status != OrderProposal.Status.APPLIED:
511
691
  break
512
692
  next_order_proposal = last_order_proposal.next_order_proposal
513
693
  if next_order_proposal:
@@ -546,8 +726,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
546
726
  else:
547
727
  last_order_proposal_created = False
548
728
  last_order_proposal = next_order_proposal
549
- if broadcast_changes_at_date:
550
- self.portfolio.builder.schedule_change_at_dates(synchronous=False, evaluate_rebalancer=False)
729
+ self.portfolio.builder.schedule_change_at_dates(
730
+ synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
731
+ )
551
732
 
552
733
  def invalidate_future_order_proposal(self):
553
734
  # Delete all future automatic order proposals and set the manual one into a draft state
@@ -555,7 +736,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
555
736
  trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
556
737
  ).delete()
557
738
  for future_order_proposal in self.portfolio.order_proposals.filter(
558
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
739
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
559
740
  ):
560
741
  future_order_proposal.revert()
561
742
  future_order_proposal.save()
@@ -577,6 +758,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
577
758
  """
578
759
  # Retrieve the price of the underlying quote on the order date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
579
760
 
761
+ # if an order exists for this estimation and the target weight is 0, then we return the inverse of the effective shares
762
+ with suppress(Order.DoesNotExist):
763
+ order = self.get_orders().get(underlying_instrument=underlying_quote)
764
+ if order.target_weight == 0:
765
+ return -order.effective_shares
580
766
  # Calculate the order's total value in the portfolio's currency
581
767
  trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
582
768
 
@@ -614,9 +800,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
614
800
  # Retrieve orders with base information
615
801
  orders = self.get_orders()
616
802
  # Calculate the total target weight of all orders
617
- total_target_weight = orders.exclude(underlying_instrument__is_cash=True).aggregate(
618
- s=models.Sum("target_weight")
619
- )["s"] or Decimal(0)
803
+ total_target_weight = orders.filter(
804
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
805
+ ).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
620
806
  if target_cash_weight is None:
621
807
  target_cash_weight = Decimal("1") - total_target_weight
622
808
 
@@ -657,7 +843,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
657
843
  @transition(
658
844
  field=status,
659
845
  source=Status.DRAFT,
660
- target=Status.SUBMIT,
846
+ target=Status.PENDING,
661
847
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
662
848
  user.profile, portfolio=instance.portfolio
663
849
  ),
@@ -720,18 +906,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
720
906
  del self.__dict__["validated_trading_service"]
721
907
  return errors
722
908
 
723
- @property
724
- def can_be_approved_or_denied(self):
725
- return not self.has_non_successful_checks and self.portfolio.is_manageable
726
-
727
909
  @transition(
728
910
  field=status,
729
- source=Status.SUBMIT,
911
+ source=Status.PENDING,
730
912
  target=Status.APPROVED,
731
913
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
732
914
  user.profile, portfolio=instance.portfolio
733
915
  )
734
- and instance.can_be_approved_or_denied,
916
+ and not instance.has_non_successful_checks,
735
917
  custom={
736
918
  "_transition_button": ActionButton(
737
919
  method=RequestType.PATCH,
@@ -744,7 +926,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
744
926
  )
745
927
  },
746
928
  )
747
- def approve(self, by=None, description=None, replay: bool = True, **kwargs):
929
+ def approve(self, by=None, description=None, **kwargs):
930
+ if by:
931
+ self.approver = getattr(by, "profile", None)
932
+ elif not self.approver:
933
+ self.approver = self.creator
934
+
935
+ def can_approve(self):
936
+ pass
937
+
938
+ @transition(
939
+ field=status,
940
+ source=Status.PENDING,
941
+ target=Status.DENIED,
942
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
943
+ user.profile, portfolio=instance.portfolio
944
+ )
945
+ and not instance.has_non_successful_checks,
946
+ custom={
947
+ "_transition_button": ActionButton(
948
+ method=RequestType.PATCH,
949
+ identifiers=("wbportfolio:orderproposal",),
950
+ icon=WBIcon.DENY.icon,
951
+ key="deny",
952
+ label="Deny",
953
+ action_label="Deny",
954
+ # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
955
+ )
956
+ },
957
+ )
958
+ def deny(self, by=None, description=None, **kwargs):
959
+ pass
960
+
961
+ def can_deny(self):
962
+ pass
963
+
964
+ @property
965
+ def can_be_applied(self):
966
+ return not self.has_non_successful_checks and self.portfolio.is_manageable
967
+
968
+ @transition(
969
+ field=status,
970
+ source=Status.APPROVED,
971
+ target=Status.APPLIED,
972
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
973
+ user.profile, portfolio=instance.portfolio
974
+ )
975
+ and instance.can_be_applied,
976
+ custom={
977
+ "_transition_button": ActionButton(
978
+ method=RequestType.PATCH,
979
+ identifiers=("wbportfolio:orderproposal",),
980
+ icon=WBIcon.SAVE.icon,
981
+ key="apply",
982
+ label="Apply",
983
+ action_label="Apply",
984
+ )
985
+ },
986
+ )
987
+ def apply(self, by=None, description=None, replay: bool = True, **kwargs):
748
988
  # We validate order which will create or update the initial asset positions
749
989
  if not self.portfolio.can_be_rebalanced:
750
990
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
@@ -755,39 +995,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
755
995
  for order in self.get_orders():
756
996
  with suppress(ValueError):
757
997
  # we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
758
- if order.underlying_instrument != estimated_cash_position.underlying_quote:
998
+ if (
999
+ order.underlying_instrument != estimated_cash_position.underlying_quote
1000
+ and order._target_weight > 0
1001
+ ):
759
1002
  assets[order.underlying_instrument.id] = order._target_weight
760
1003
 
761
1004
  # if there is cash leftover, we create an extra asset position to hold the cash component
762
- if estimated_cash_position.weighting and len(assets) > 0:
1005
+ if estimated_cash_position.weighting > 0 and len(assets) > 0:
763
1006
  warnings.append(
764
1007
  f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
765
1008
  )
766
1009
  assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
767
-
768
1010
  self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
769
- force_save=True, is_estimated=False
1011
+ force_save=True, is_estimated=False, delete_leftovers=True
770
1012
  )
771
1013
  if replay and self.portfolio.is_manageable:
772
- replay_as_task.delay(self.id, user_id=by.id if by else None, broadcast_changes_at_date=False)
1014
+ replay_as_task.delay(
1015
+ self.id, user_id=by.id if by else None, broadcast_changes_at_date=False, reapply_order_proposal=True
1016
+ )
1017
+ if by:
1018
+ self.approver = by.profile
773
1019
  return warnings
774
1020
 
775
- def can_approve(self):
1021
+ def can_apply(self):
776
1022
  errors = dict()
777
1023
  orders = self.get_orders()
778
1024
  if not self.portfolio.can_be_rebalanced:
779
1025
  errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
780
1026
  if not orders.exists():
781
1027
  errors["non_field_errors"] = [
782
- _("At least one order needs to be submitted to be able to approve this proposal")
1028
+ _("At least one order needs to be submitted to be able to apply this proposal")
783
1029
  ]
784
1030
  if not self.portfolio.can_be_rebalanced:
785
1031
  errors["portfolio"] = [
786
- [_("The portfolio needs to be a model portfolio in order to approve this order proposal manually")]
1032
+ [_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
787
1033
  ]
788
1034
  if self.has_non_successful_checks:
789
1035
  errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
790
- if orders.filter(has_warnings=True).exclude(underlying_instrument__is_cash=True):
1036
+ if orders.filter(has_warnings=True).filter(
1037
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
1038
+ ):
791
1039
  errors["non_field_errors"] = [
792
1040
  _("There is warning that needs to be addresses on the orders before approval.")
793
1041
  ]
@@ -795,40 +1043,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
795
1043
 
796
1044
  @transition(
797
1045
  field=status,
798
- source=Status.SUBMIT,
799
- target=Status.DENIED,
800
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
801
- user.profile, portfolio=instance.portfolio
802
- )
803
- and instance.can_be_approved_or_denied,
804
- custom={
805
- "_transition_button": ActionButton(
806
- method=RequestType.PATCH,
807
- identifiers=("wbportfolio:orderproposal",),
808
- icon=WBIcon.DENY.icon,
809
- key="deny",
810
- label="Deny",
811
- action_label="Deny",
812
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
813
- )
814
- },
815
- )
816
- def deny(self, by=None, description=None, **kwargs):
817
- self.orders.all().delete()
818
- with suppress(KeyError):
819
- del self.__dict__["validated_trading_service"]
820
-
821
- def can_deny(self):
822
- errors = dict()
823
- if not self.orders.all().exists():
824
- errors["non_field_errors"] = [
825
- _("At least one order needs to be submitted to be able to deny this proposal")
826
- ]
827
- return errors
828
-
829
- @transition(
830
- field=status,
831
- source=Status.SUBMIT,
1046
+ source=Status.PENDING,
832
1047
  target=Status.DRAFT,
833
1048
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
834
1049
  user.profile, portfolio=instance.portfolio
@@ -857,7 +1072,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
857
1072
 
858
1073
  @transition(
859
1074
  field=status,
860
- source=Status.APPROVED,
1075
+ source=Status.APPLIED,
861
1076
  target=Status.DRAFT,
862
1077
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
863
1078
  user.profile, portfolio=instance.portfolio
@@ -875,6 +1090,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
875
1090
  },
876
1091
  )
877
1092
  def revert(self, **kwargs):
1093
+ self.approver = None
878
1094
  with suppress(KeyError):
879
1095
  del self.__dict__["validated_trading_service"]
880
1096
  self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
@@ -889,6 +1105,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
889
1105
  ]
890
1106
  return errors
891
1107
 
1108
+ @transition(
1109
+ field=status,
1110
+ source=Status.APPROVED,
1111
+ target=Status.EXECUTION,
1112
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1113
+ user.profile, portfolio=instance.portfolio
1114
+ )
1115
+ and (user != instance.approver or user.is_superuser)
1116
+ and instance.custodian_adapter
1117
+ and not instance.has_non_successful_checks,
1118
+ custom={
1119
+ "_transition_button": ActionButton(
1120
+ method=RequestType.PATCH,
1121
+ identifiers=("wbportfolio:orderproposal",),
1122
+ icon=WBIcon.DEAL_MONEY.icon,
1123
+ key="execute",
1124
+ label="Execute",
1125
+ action_label="Execute",
1126
+ description_fields="<p>Execute the orders through the setup custodian.</p>",
1127
+ )
1128
+ },
1129
+ )
1130
+ def execute(self, **kwargs):
1131
+ self.execution_status = ExecutionStatus.PENDING
1132
+ self.execution_comment = "Waiting for custodian confirmation"
1133
+ execute_orders_as_task.delay(self.id)
1134
+
1135
+ def can_execute(self):
1136
+ if not self.custodian_adapter:
1137
+ return {"portfolio": ["No custodian adapter"]}
1138
+
1139
+ @transition(
1140
+ field=status,
1141
+ source=Status.EXECUTION,
1142
+ target=Status.APPROVED,
1143
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
1144
+ user.profile, portfolio=instance.portfolio
1145
+ )
1146
+ and instance.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT],
1147
+ custom={
1148
+ "_transition_button": ActionButton(
1149
+ method=RequestType.PATCH,
1150
+ identifiers=("wbportfolio:orderproposal",),
1151
+ icon=WBIcon.PREVIOUS.icon,
1152
+ key="cancelexecution",
1153
+ label="Cancel Execution",
1154
+ action_label="Cancel Execution",
1155
+ description_fields="<p>Cancel the current requested execution. Time sensitive operation.</p>",
1156
+ )
1157
+ },
1158
+ )
1159
+ def cancelexecution(self, **kwargs):
1160
+ warning = ""
1161
+ try:
1162
+ if cancel_rebalancing(self):
1163
+ self.execution_comment, self.execution_status_detail, self.execution_status = (
1164
+ "",
1165
+ "",
1166
+ ExecutionStatus.CANCELLED,
1167
+ )
1168
+ else:
1169
+ warning = "We could not cancel the rebalancing. It is probably already executed. Please refresh status or check with an administrator."
1170
+ except (RoutingException, ValueError) as e:
1171
+ warning = f"Could not cancel orders proposal {self}: {str(e)}"
1172
+ logger.error(warning)
1173
+ return warning
1174
+
1175
+ def can_cancelexecution(self):
1176
+ if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1177
+ return {"execution_status": "Execution can only be cancelled if it is not already executed"}
1178
+
1179
+ def update_execution_status(self):
1180
+ try:
1181
+ self.execution_status, self.execution_status_detail = get_execution_status(self)
1182
+ self.save()
1183
+ except (RoutingException, ValueError) as e:
1184
+ logger.warning(f"Could not update rebalancing status: {str(e)}")
1185
+
892
1186
  # End FSM logics
893
1187
 
894
1188
  @classmethod
@@ -914,7 +1208,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
914
1208
  if not raw and instance.status == OrderProposal.Status.FAILED:
915
1209
  # we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
916
1210
  instance.invalidate_future_order_proposal()
917
- instance.invalidate_future_order_proposal()
918
1211
 
919
1212
 
920
1213
  @shared_task(queue="portfolio")
@@ -931,3 +1224,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
931
1224
  reverse_name="wbportfolio:portfolio-detail",
932
1225
  reverse_args=[order_proposal.portfolio.id],
933
1226
  )
1227
+
1228
+
1229
+ @shared_task(queue="portfolio")
1230
+ def execute_orders_as_task(order_proposal_id: int):
1231
+ order_proposal = OrderProposal.objects.get(id=order_proposal_id)
1232
+ try:
1233
+ status, comment = execute_orders(order_proposal)
1234
+ except (ValueError, RoutingException) as e:
1235
+ logger.error(f"Could not execute orders proposal {order_proposal}: {str(e)}")
1236
+ status = ExecutionStatus.FAILED
1237
+ comment = str(e)
1238
+ order_proposal.execution_status = status
1239
+ order_proposal.execution_comment = comment
1240
+ order_proposal.save()
1241
+
1242
+
1243
+ @shared_task(queue="portfolio")
1244
+ def push_model_change_as_task(
1245
+ model_order_proposal_id: int,
1246
+ user_id: int,
1247
+ only_for_portfolio_ids: list[int] | None = None,
1248
+ approve_automatically: bool = False,
1249
+ ):
1250
+ # not happy with that but we will keep it for the MVP lifecycle
1251
+ model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
1252
+ trade_date = model_order_proposal.trade_date
1253
+ user = User.objects.get(id=user_id)
1254
+ from wbportfolio.models.rebalancing import RebalancingModel
1255
+
1256
+ model_rebalancing = RebalancingModel.objects.get(
1257
+ class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
1258
+ )
1259
+ product_html_list = "<ul>\n"
1260
+ for rel in model_order_proposal.portfolio.get_model_portfolio_relationships(trade_date):
1261
+ if not only_for_portfolio_ids or rel.portfolio.id in only_for_portfolio_ids:
1262
+ order_proposal, _ = OrderProposal.objects.update_or_create(
1263
+ portfolio=rel.portfolio, trade_date=trade_date, defaults={"rebalancing_model": model_rebalancing}
1264
+ )
1265
+ order_proposal.reset_orders()
1266
+ product_html_list += f"<li>{rel.portfolio}</li>\n"
1267
+ if approve_automatically:
1268
+ order_proposal.submit()
1269
+ order_proposal.approve(by=user)
1270
+ order_proposal.save()
1271
+ product_html_list += "</ul>"
1272
+
1273
+ send_notification(
1274
+ code="wbportfolio.order_proposal.push_model_changes",
1275
+ title="Portfolio Model changes are pushed to dependant portfolios",
1276
+ body=f"""
1277
+ <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>
1278
+ <p>To proceed with executing these orders, please review the following related portfolios: </p>
1279
+ {product_html_list}
1280
+ """,
1281
+ user=user,
1282
+ )