wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.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 (64) 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/handlers/trade.py +2 -2
  19. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  20. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  21. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  22. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  23. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  24. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  25. wbportfolio/models/builder.py +70 -25
  26. wbportfolio/models/mixins/instruments.py +7 -0
  27. wbportfolio/models/orders/order_proposals.py +512 -161
  28. wbportfolio/models/orders/orders.py +20 -10
  29. wbportfolio/models/orders/routing.py +54 -0
  30. wbportfolio/models/portfolio.py +76 -41
  31. wbportfolio/models/rebalancing.py +6 -6
  32. wbportfolio/models/transactions/transactions.py +10 -6
  33. wbportfolio/order_routing/__init__.py +19 -0
  34. wbportfolio/order_routing/adapters/__init__.py +57 -0
  35. wbportfolio/order_routing/adapters/ubs.py +161 -0
  36. wbportfolio/pms/trading/handler.py +4 -0
  37. wbportfolio/pms/typing.py +62 -8
  38. wbportfolio/rebalancing/models/composite.py +1 -1
  39. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  40. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  41. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  42. wbportfolio/serializers/orders/order_proposals.py +23 -3
  43. wbportfolio/serializers/orders/orders.py +5 -2
  44. wbportfolio/serializers/positions.py +2 -2
  45. wbportfolio/serializers/rebalancing.py +1 -1
  46. wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
  47. wbportfolio/tests/models/test_imports.py +7 -6
  48. wbportfolio/tests/models/test_portfolios.py +57 -23
  49. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  50. wbportfolio/tests/rebalancing/test_models.py +3 -5
  51. wbportfolio/viewsets/charts/assets.py +4 -1
  52. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  53. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  54. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  55. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
  56. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  57. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  58. wbportfolio/viewsets/orders/order_proposals.py +42 -4
  59. wbportfolio/viewsets/orders/orders.py +33 -31
  60. wbportfolio/viewsets/portfolios.py +3 -3
  61. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/METADATA +1 -1
  62. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/RECORD +64 -54
  63. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/WHEEL +0 -0
  64. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.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,72 @@ 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 (
323
+ self.get_orders().exclude(underlying_instrument__is_cash=True).select_related("underlying_instrument")
324
+ ):
325
+ instrument = order.underlying_instrument
326
+ # we support only the instrument type provided by the Order DTO class
327
+ asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
328
+ try:
329
+ if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
330
+ executable_orders.append(
331
+ OrderDTO(
332
+ id=order.id,
333
+ asset_class=OrderDTO.AssetType[asset_class],
334
+ weighting=float(order.weighting),
335
+ target_weight=float(order.target_weight),
336
+ 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
+ refinitiv_identifier_code=instrument.refinitiv_identifier_code,
340
+ bloomberg_ticker=instrument.ticker,
341
+ sedol=instrument.sedol,
342
+ execution_instruction=self.execution_instruction,
343
+ )
344
+ )
345
+ else:
346
+ order.execution_confirmed = False
347
+ order.execution_comment = "Underlying instrument does not have a valid identifier."
348
+ order.save()
349
+ except (AttributeError, KeyError):
350
+ order.execution_confirmed = False
351
+ order.execution_comment = f"Unsupported asset class {asset_class.title()}."
352
+ order.save()
353
+
354
+ return executable_orders
355
+
247
356
  def __str__(self) -> str:
248
357
  return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
249
358
 
@@ -251,56 +360,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
251
360
  self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
252
361
  ) -> PortfolioDTO:
253
362
  """
254
- Data Transfer Object
363
+ Converts the internal portfolio state and pending orders into a PortfolioDTO.
364
+
365
+ 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
+
255
370
  Returns:
256
- DTO order object
371
+ PortfolioDTO: Object that encapsulates all portfolio positions.
257
372
  """
258
373
  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
374
+
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
+ }
389
+
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
+ ):
269
394
  if use_desired_target_weight and order.desired_target_weight is not None:
270
- delta_weight = order.desired_target_weight - previous_weight
395
+ delta_weight = Decimal("0")
396
+ weighting = order.desired_target_weight
271
397
  else:
272
398
  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()))
399
+ weighting = order._previous_weight
400
+
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
+ }
408
+
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()}
411
+
412
+ # 4. Attempt to fetch analytic returns and portfolio contribution. Default on error.
281
413
  try:
282
- last_returns, portfolio_contribution = self.portfolio.get_analytic_portfolio(
414
+ last_returns, contribution = self.portfolio.get_analytic_portfolio(
283
415
  self.value_date, weights=previous_weights, use_dl=True
284
416
  ).get_contributions()
285
417
  last_returns = last_returns.to_dict()
286
418
  except ValueError:
287
- last_returns, portfolio_contribution = {}, 1
419
+ last_returns, contribution = {}, 1
420
+
288
421
  positions = []
289
422
  total_weighting = Decimal("0")
423
+
424
+ # 5. Build PositionDTO objects for all instruments.
290
425
  for instrument, row in portfolio.items():
291
426
  weighting = row["weighting"]
292
427
  daily_return = Decimal(last_returns.get(instrument.id, 0))
293
428
 
294
- if not use_effective:
295
- drifted_weight = (
296
- round(
297
- weighting * (daily_return + Decimal("1")) / Decimal(portfolio_contribution),
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),
298
434
  Order.ORDER_WEIGHTING_PRECISION,
299
435
  )
300
- if portfolio_contribution
301
- else weighting
302
- )
436
+ else:
437
+ drifted_weight = weighting
303
438
  weighting = drifted_weight + row["delta_weight"]
439
+
440
+ # Assemble the position object
304
441
  positions.append(
305
442
  PositionDTO(
306
443
  underlying_instrument=instrument.id,
@@ -316,9 +453,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
316
453
  )
317
454
  )
318
455
  total_weighting += weighting
319
- if portfolio and with_cash and total_weighting and (cash_weight := Decimal("1") - total_weighting):
456
+
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
+ ):
320
465
  cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
321
466
  positions.append(cash_position._build_dto())
467
+
322
468
  return PortfolioDTO(positions)
323
469
 
324
470
  # Start tools methods
@@ -374,10 +520,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
374
520
 
375
521
  def fix_quantization(self):
376
522
  if self.orders.exists():
377
- t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
523
+ orders = self.get_orders()
524
+ t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
378
525
  # 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
526
  if quantize_error := (t_weight - self.total_expected_target_weight):
380
- biggest_order = self.orders.latest("weighting")
527
+ biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
381
528
  biggest_order.weighting -= quantize_error
382
529
  biggest_order.save()
383
530
 
@@ -407,6 +554,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
407
554
  """
408
555
  if self.rebalancing_model:
409
556
  self.orders.all().delete()
557
+ else:
558
+ self.orders.filter(underlying_instrument__is_cash=True).delete()
410
559
  # delete all existing orders
411
560
  # Get effective and target portfolio
412
561
  if not target_portfolio:
@@ -426,6 +575,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
426
575
  else:
427
576
  orders = service.trades_batch.trades_map.values()
428
577
 
578
+ objs = []
429
579
  for order_dto in orders:
430
580
  instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
431
581
  currency_fx_rate = instrument.currency.convert(
@@ -448,30 +598,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
448
598
  daily_return=daily_return,
449
599
  currency_fx_rate=currency_fx_rate,
450
600
  )
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()
601
+ order.desired_target_weight = order_dto.target_weight
602
+ order.order_type = Order.get_type(
603
+ weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
604
+ )
605
+ 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
610
+ objs.append(order)
611
+ Order.objects.bulk_create(
612
+ objs,
613
+ update_fields=[
614
+ "value_date",
615
+ "weighting",
616
+ "daily_return",
617
+ "currency_fx_rate",
618
+ "order_type",
619
+ "portfolio",
620
+ "price",
621
+ "price_gross",
622
+ "desired_target_weight",
623
+ # "shares"
624
+ ],
625
+ unique_fields=["order_proposal", "underlying_instrument"],
626
+ update_conflicts=True,
627
+ batch_size=1000,
628
+ )
458
629
  # 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()
630
+ self.get_orders().exclude(underlying_instrument__is_cash=True).filter(
631
+ target_weight=0, effective_weight=0
632
+ ).delete()
633
+ 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
636
+ self.save()
466
637
 
467
- def approve_workflow(
638
+ def apply_workflow(
468
639
  self,
469
- approve_automatically: bool = True,
640
+ apply_automatically: bool = True,
470
641
  silent_exception: bool = False,
471
642
  force_reset_order: bool = False,
472
643
  **reset_order_kwargs,
473
644
  ):
474
- if self.status == OrderProposal.Status.APPROVED:
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:
475
648
  logger.info("Reverting order proposal ...")
476
649
  self.revert()
477
650
  if self.status == OrderProposal.Status.DRAFT:
@@ -488,26 +661,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
488
661
  return
489
662
  logger.info("Submitting order proposal ...")
490
663
  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)
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)
495
669
 
496
- def replay(self, broadcast_changes_at_date: bool = True, reapply_order_proposal: bool = False):
670
+ def replay(
671
+ self,
672
+ broadcast_changes_at_date: bool = True,
673
+ reapply_order_proposal: bool = False,
674
+ synchronous: bool = False,
675
+ **reset_order_kwargs,
676
+ ):
497
677
  last_order_proposal = self
498
678
  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:
679
+ 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:
501
681
  last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
502
682
  if not last_order_proposal_created:
503
- if reapply_order_proposal:
683
+ if reapply_order_proposal or last_order_proposal.rebalancing_model:
504
684
  logger.info(f"Replaying order proposal {last_order_proposal}")
505
- last_order_proposal.approve_workflow(silent_exception=True, force_reset_order=True)
685
+ last_order_proposal.apply_workflow(
686
+ silent_exception=True, force_reset_order=True, **reset_order_kwargs
687
+ )
506
688
  last_order_proposal.save()
507
689
  else:
508
690
  logger.info(f"Resetting order proposal {last_order_proposal}")
509
- last_order_proposal.reset_orders()
510
- if last_order_proposal.status != OrderProposal.Status.APPROVED:
691
+ last_order_proposal.reset_orders(**reset_order_kwargs)
692
+ if last_order_proposal.status != OrderProposal.Status.APPLIED:
511
693
  break
512
694
  next_order_proposal = last_order_proposal.next_order_proposal
513
695
  if next_order_proposal:
@@ -546,8 +728,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
546
728
  else:
547
729
  last_order_proposal_created = False
548
730
  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)
731
+ self.portfolio.builder.schedule_change_at_dates(
732
+ synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
733
+ )
551
734
 
552
735
  def invalidate_future_order_proposal(self):
553
736
  # Delete all future automatic order proposals and set the manual one into a draft state
@@ -555,7 +738,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
555
738
  trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
556
739
  ).delete()
557
740
  for future_order_proposal in self.portfolio.order_proposals.filter(
558
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
741
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
559
742
  ):
560
743
  future_order_proposal.revert()
561
744
  future_order_proposal.save()
@@ -577,6 +760,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
577
760
  """
578
761
  # 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
762
 
763
+ # if an order exists for this estimation and the target weight is 0, then we return the inverse of the effective shares
764
+ with suppress(Order.DoesNotExist):
765
+ order = self.get_orders().get(underlying_instrument=underlying_quote)
766
+ if order.target_weight == 0:
767
+ return -order.effective_shares
580
768
  # Calculate the order's total value in the portfolio's currency
581
769
  trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
582
770
 
@@ -614,9 +802,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
614
802
  # Retrieve orders with base information
615
803
  orders = self.get_orders()
616
804
  # 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)
805
+ total_target_weight = orders.filter(
806
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
807
+ ).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
620
808
  if target_cash_weight is None:
621
809
  target_cash_weight = Decimal("1") - total_target_weight
622
810
 
@@ -657,7 +845,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
657
845
  @transition(
658
846
  field=status,
659
847
  source=Status.DRAFT,
660
- target=Status.SUBMIT,
848
+ target=Status.PENDING,
661
849
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
662
850
  user.profile, portfolio=instance.portfolio
663
851
  ),
@@ -720,18 +908,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
720
908
  del self.__dict__["validated_trading_service"]
721
909
  return errors
722
910
 
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
911
  @transition(
728
912
  field=status,
729
- source=Status.SUBMIT,
913
+ source=Status.PENDING,
730
914
  target=Status.APPROVED,
731
915
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
732
916
  user.profile, portfolio=instance.portfolio
733
917
  )
734
- and instance.can_be_approved_or_denied,
918
+ and not instance.has_non_successful_checks,
735
919
  custom={
736
920
  "_transition_button": ActionButton(
737
921
  method=RequestType.PATCH,
@@ -744,7 +928,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
744
928
  )
745
929
  },
746
930
  )
747
- def approve(self, by=None, description=None, replay: bool = True, **kwargs):
931
+ def approve(self, by=None, description=None, **kwargs):
932
+ if by:
933
+ self.approver = getattr(by, "profile", None)
934
+ elif not self.approver:
935
+ self.approver = self.creator
936
+
937
+ def can_approve(self):
938
+ pass
939
+
940
+ @transition(
941
+ field=status,
942
+ source=Status.PENDING,
943
+ target=Status.DENIED,
944
+ permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
945
+ user.profile, portfolio=instance.portfolio
946
+ )
947
+ and not instance.has_non_successful_checks,
948
+ custom={
949
+ "_transition_button": ActionButton(
950
+ method=RequestType.PATCH,
951
+ identifiers=("wbportfolio:orderproposal",),
952
+ icon=WBIcon.DENY.icon,
953
+ key="deny",
954
+ label="Deny",
955
+ action_label="Deny",
956
+ # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
957
+ )
958
+ },
959
+ )
960
+ def deny(self, by=None, description=None, **kwargs):
961
+ pass
962
+
963
+ def can_deny(self):
964
+ pass
965
+
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):
748
990
  # We validate order which will create or update the initial asset positions
749
991
  if not self.portfolio.can_be_rebalanced:
750
992
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
@@ -755,39 +997,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
755
997
  for order in self.get_orders():
756
998
  with suppress(ValueError):
757
999
  # 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:
1000
+ if (
1001
+ order.underlying_instrument != estimated_cash_position.underlying_quote
1002
+ and order._target_weight > 0
1003
+ ):
759
1004
  assets[order.underlying_instrument.id] = order._target_weight
760
1005
 
761
1006
  # 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:
1007
+ if estimated_cash_position.weighting > 0 and len(assets) > 0:
763
1008
  warnings.append(
764
1009
  f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
765
1010
  )
766
1011
  assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
767
-
768
1012
  self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
769
- force_save=True, is_estimated=False
1013
+ force_save=True, is_estimated=False, delete_leftovers=True
770
1014
  )
771
1015
  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)
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
773
1021
  return warnings
774
1022
 
775
- def can_approve(self):
1023
+ def can_apply(self):
776
1024
  errors = dict()
777
1025
  orders = self.get_orders()
778
1026
  if not self.portfolio.can_be_rebalanced:
779
1027
  errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
780
1028
  if not orders.exists():
781
1029
  errors["non_field_errors"] = [
782
- _("At least one order needs to be submitted to be able to approve this proposal")
1030
+ _("At least one order needs to be submitted to be able to apply this proposal")
783
1031
  ]
784
1032
  if not self.portfolio.can_be_rebalanced:
785
1033
  errors["portfolio"] = [
786
- [_("The portfolio needs to be a model portfolio in order to approve this order proposal manually")]
1034
+ [_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
787
1035
  ]
788
1036
  if self.has_non_successful_checks:
789
1037
  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):
1038
+ if orders.filter(has_warnings=True).filter(
1039
+ underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
1040
+ ):
791
1041
  errors["non_field_errors"] = [
792
1042
  _("There is warning that needs to be addresses on the orders before approval.")
793
1043
  ]
@@ -795,40 +1045,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
795
1045
 
796
1046
  @transition(
797
1047
  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,
1048
+ source=Status.PENDING,
832
1049
  target=Status.DRAFT,
833
1050
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
834
1051
  user.profile, portfolio=instance.portfolio
@@ -857,7 +1074,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
857
1074
 
858
1075
  @transition(
859
1076
  field=status,
860
- source=Status.APPROVED,
1077
+ source=Status.APPLIED,
861
1078
  target=Status.DRAFT,
862
1079
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
863
1080
  user.profile, portfolio=instance.portfolio
@@ -875,6 +1092,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
875
1092
  },
876
1093
  )
877
1094
  def revert(self, **kwargs):
1095
+ self.approver = None
878
1096
  with suppress(KeyError):
879
1097
  del self.__dict__["validated_trading_service"]
880
1098
  self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
@@ -889,6 +1107,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
889
1107
  ]
890
1108
  return errors
891
1109
 
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
+ )
1170
+ 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)}")
1187
+
892
1188
  # End FSM logics
893
1189
 
894
1190
  @classmethod
@@ -914,7 +1210,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
914
1210
  if not raw and instance.status == OrderProposal.Status.FAILED:
915
1211
  # we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
916
1212
  instance.invalidate_future_order_proposal()
917
- instance.invalidate_future_order_proposal()
918
1213
 
919
1214
 
920
1215
  @shared_task(queue="portfolio")
@@ -931,3 +1226,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
931
1226
  reverse_name="wbportfolio:portfolio-detail",
932
1227
  reverse_args=[order_proposal.portfolio.id],
933
1228
  )
1229
+
1230
+
1231
+ @shared_task(queue="portfolio")
1232
+ def execute_orders_as_task(order_proposal_id: int):
1233
+ 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")
1246
+ def push_model_change_as_task(
1247
+ model_order_proposal_id: int,
1248
+ user_id: int,
1249
+ only_for_portfolio_ids: list[int] | None = None,
1250
+ approve_automatically: bool = False,
1251
+ ):
1252
+ # not happy with that but we will keep it for the MVP lifecycle
1253
+ 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
1257
+
1258
+ model_rebalancing = RebalancingModel.objects.get(
1259
+ class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
1260
+ )
1261
+ 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()
1273
+ product_html_list += "</ul>"
1274
+
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
+ )