wbportfolio 1.54.22__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 (79) 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/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.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,9 +85,34 @@ 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
  )
99
+ total_cash_weight = models.DecimalField(
100
+ default=Decimal("0"),
101
+ decimal_places=4,
102
+ max_digits=5,
103
+ verbose_name="Total Cash Weight",
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%.",
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")
84
116
 
85
117
  class Meta:
86
118
  verbose_name = "Order Proposal"
@@ -92,6 +124,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
92
124
  ),
93
125
  ]
94
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
+
95
138
  def save(self, *args, **kwargs):
96
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
97
140
  if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
@@ -134,6 +177,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
134
177
  except AssetPosition.DoesNotExist:
135
178
  return self.value_date
136
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
+
137
187
  @cached_property
138
188
  def value_date(self) -> date:
139
189
  return (self.trade_date - BDay(1)).date()
@@ -141,7 +191,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
141
191
  @property
142
192
  def previous_order_proposal(self) -> SelfOrderProposal | None:
143
193
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
144
- trade_date__lt=self.trade_date, status=OrderProposal.Status.APPROVED
194
+ trade_date__lt=self.trade_date, status=OrderProposal.Status.APPLIED
145
195
  )
146
196
  if future_proposals.exists():
147
197
  return future_proposals.latest("trade_date")
@@ -150,7 +200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
150
200
  @property
151
201
  def next_order_proposal(self) -> SelfOrderProposal | None:
152
202
  future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
153
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
203
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
154
204
  )
155
205
  if future_proposals.exists():
156
206
  return future_proposals.earliest("trade_date")
@@ -158,12 +208,34 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
158
208
 
159
209
  @property
160
210
  def cash_component(self) -> Cash:
161
- return Cash.objects.get_or_create(
162
- currency=self.portfolio.currency, defaults={"is_cash": True, "name": self.portfolio.currency.title}
163
- )[0]
211
+ return self.portfolio.cash_component
212
+
213
+ @property
214
+ def total_effective_portfolio_weight(self) -> Decimal:
215
+ return Decimal("1.0")
216
+
217
+ @property
218
+ def total_expected_target_weight(self) -> Decimal:
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"
164
232
 
165
233
  def get_orders(self):
166
- 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),
167
239
  last_effective_date=Subquery(
168
240
  AssetPosition.unannotated_objects.filter(
169
241
  date__lt=OuterRef("value_date"),
@@ -172,27 +244,33 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
172
244
  .order_by("-date")
173
245
  .values("date")[:1]
174
246
  ),
175
- previous_weight=Coalesce(
176
- Subquery(
177
- AssetPosition.unannotated_objects.filter(
178
- underlying_quote=OuterRef("underlying_instrument"),
179
- date=OuterRef("last_effective_date"),
180
- portfolio=OuterRef("portfolio"),
181
- )
182
- .values("portfolio")
183
- .annotate(s=Sum("weighting"))
184
- .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
+ ),
185
263
  ),
186
- Decimal(0),
264
+ default=Value(self.total_effective_portfolio_cash_weight),
187
265
  ),
188
266
  contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
189
- )
190
- portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
191
- orders = base_qs.annotate(
192
267
  effective_weight=Round(
193
- 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,
194
273
  ),
195
- tmp_effective_weight=F("contribution") / Value(portfolio_contribution),
196
274
  target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
197
275
  effective_shares=Coalesce(
198
276
  Subquery(
@@ -209,79 +287,155 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
209
287
  ),
210
288
  target_shares=F("effective_shares") + F("shares"),
211
289
  )
212
- total_effective_weight = orders.aggregate(s=models.Sum("effective_weight"))["s"] or Decimal("1")
213
- with suppress(Order.DoesNotExist):
214
- largest_order = orders.latest("effective_weight")
215
- if quant_error := Decimal("1") - total_effective_weight:
216
- orders = orders.annotate(
217
- effective_weight=models.Case(
218
- models.When(
219
- 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"),
220
302
  ),
221
- default=models.F("effective_weight"),
222
- ),
223
- target_weight=models.Case(
224
- models.When(
225
- 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"),
226
309
  ),
227
- default=models.F("target_weight"),
228
- ),
229
- )
310
+ )
230
311
  return orders.annotate(
231
312
  has_warnings=models.Case(
232
- 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),
233
317
  ),
234
318
  )
235
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
+
236
354
  def __str__(self) -> str:
237
355
  return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
238
356
 
239
- def convert_to_portfolio(self, use_effective: bool = False, with_cash: bool = True) -> PortfolioDTO:
357
+ def convert_to_portfolio(
358
+ self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
359
+ ) -> PortfolioDTO:
240
360
  """
241
- 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
+
242
368
  Returns:
243
- DTO order object
369
+ PortfolioDTO: Object that encapsulates all portfolio positions.
244
370
  """
245
371
  portfolio = {}
246
- for asset in self.portfolio.assets.filter(date=self.last_effective_date):
247
- portfolio[asset.underlying_quote] = dict(
248
- shares=asset._shares,
249
- weighting=asset.weighting,
250
- delta_weight=Decimal("0"),
251
- price=asset._price,
252
- currency_fx_rate=asset._currency_fx_rate,
253
- )
254
- for order in self.get_orders():
255
- portfolio[order.underlying_instrument] = dict(
256
- weighting=order._previous_weight,
257
- delta_weight=order.weighting,
258
- shares=order._target_shares if not use_effective else order._effective_shares,
259
- price=order.price,
260
- currency_fx_rate=order.currency_fx_rate,
261
- )
262
- previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
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
+ ):
392
+ if use_desired_target_weight and order.desired_target_weight is not None:
393
+ delta_weight = Decimal("0")
394
+ weighting = order.desired_target_weight
395
+ else:
396
+ delta_weight = order.weighting
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.
263
411
  try:
264
- last_returns, portfolio_contribution = self.portfolio.get_analytic_portfolio(
412
+ last_returns, contribution = self.portfolio.get_analytic_portfolio(
265
413
  self.value_date, weights=previous_weights, use_dl=True
266
414
  ).get_contributions()
267
415
  last_returns = last_returns.to_dict()
268
416
  except ValueError:
269
- last_returns, portfolio_contribution = {}, 1
417
+ last_returns, contribution = {}, 1
418
+
270
419
  positions = []
271
420
  total_weighting = Decimal("0")
421
+
422
+ # 5. Build PositionDTO objects for all instruments.
272
423
  for instrument, row in portfolio.items():
273
424
  weighting = row["weighting"]
274
425
  daily_return = Decimal(last_returns.get(instrument.id, 0))
275
- if not use_effective:
276
- drifted_weight = (
277
- round(
278
- weighting * (daily_return + Decimal("1")) / Decimal(portfolio_contribution),
426
+
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),
279
432
  Order.ORDER_WEIGHTING_PRECISION,
280
433
  )
281
- if portfolio_contribution
282
- else weighting
283
- )
434
+ else:
435
+ drifted_weight = weighting
284
436
  weighting = drifted_weight + row["delta_weight"]
437
+
438
+ # Assemble the position object
285
439
  positions.append(
286
440
  PositionDTO(
287
441
  underlying_instrument=instrument.id,
@@ -297,9 +451,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
297
451
  )
298
452
  )
299
453
  total_weighting += weighting
300
- if portfolio and with_cash 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
+ ):
301
463
  cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
302
464
  positions.append(cash_position._build_dto())
465
+
303
466
  return PortfolioDTO(positions)
304
467
 
305
468
  # Start tools methods
@@ -332,7 +495,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
332
495
 
333
496
  return order_proposal_clone
334
497
 
335
- def normalize_orders(self, total_target_weight: Decimal = Decimal("1.0")):
498
+ def normalize_orders(self):
336
499
  """
337
500
  Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
338
501
  The existing order will be modified directly with the given normalization factor
@@ -341,7 +504,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
341
504
  self.trade_date,
342
505
  effective_portfolio=self._get_default_effective_portfolio(),
343
506
  target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
344
- total_target_weight=total_target_weight,
507
+ total_target_weight=self.total_expected_target_weight,
345
508
  )
346
509
  leftovers_orders = self.orders.all()
347
510
  for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
@@ -351,14 +514,19 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
351
514
  order.save()
352
515
  leftovers_orders = leftovers_orders.exclude(id=order.id)
353
516
  leftovers_orders.delete()
354
- t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
355
- # 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
356
- if quantize_error := (t_weight - total_target_weight):
357
- biggest_order = self.orders.latest("weighting")
358
- biggest_order.weighting -= quantize_error
359
- biggest_order.save()
360
-
361
- def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
517
+ self.fix_quantization()
518
+
519
+ def fix_quantization(self):
520
+ if self.orders.exists():
521
+ orders = self.get_orders()
522
+ t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
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
524
+ if quantize_error := (t_weight - self.total_expected_target_weight):
525
+ biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
526
+ biggest_order.weighting -= quantize_error
527
+ biggest_order.save()
528
+
529
+ def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
362
530
  if self.rebalancing_model:
363
531
  params = {}
364
532
  if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
@@ -367,7 +535,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
367
535
  return self.rebalancing_model.get_target_portfolio(
368
536
  self.portfolio, self.trade_date, self.value_date, **params
369
537
  )
370
- return self.convert_to_portfolio(use_effective=False)
538
+ return self.convert_to_portfolio(use_effective=False, use_desired_target_weight=use_desired_target_weight)
371
539
 
372
540
  def _get_default_effective_portfolio(self):
373
541
  return self.convert_to_portfolio(use_effective=True)
@@ -377,17 +545,19 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
377
545
  target_portfolio: PortfolioDTO | None = None,
378
546
  effective_portfolio: PortfolioRole | None = None,
379
547
  validate_order: bool = True,
380
- total_target_weight: Decimal = Decimal("1.0"),
548
+ use_desired_target_weight: bool = False,
381
549
  ):
382
550
  """
383
551
  Will delete all existing orders and recreate them from the method `create_or_update_trades`
384
552
  """
385
553
  if self.rebalancing_model:
386
554
  self.orders.all().delete()
555
+ else:
556
+ self.orders.filter(underlying_instrument__is_cash=True).delete()
387
557
  # delete all existing orders
388
558
  # Get effective and target portfolio
389
559
  if not target_portfolio:
390
- target_portfolio = self._get_default_target_portfolio()
560
+ target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
391
561
  if not effective_portfolio:
392
562
  effective_portfolio = self._get_default_effective_portfolio()
393
563
  if target_portfolio:
@@ -395,13 +565,15 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
395
565
  self.trade_date,
396
566
  effective_portfolio=effective_portfolio,
397
567
  target_portfolio=target_portfolio,
398
- total_target_weight=total_target_weight,
568
+ total_target_weight=self.total_expected_target_weight,
399
569
  )
400
570
  if validate_order:
401
571
  service.is_valid()
402
572
  orders = service.validated_trades
403
573
  else:
404
574
  orders = service.trades_batch.trades_map.values()
575
+
576
+ objs = []
405
577
  for order_dto in orders:
406
578
  instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
407
579
  currency_fx_rate = instrument.currency.convert(
@@ -424,25 +596,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
424
596
  daily_return=daily_return,
425
597
  currency_fx_rate=currency_fx_rate,
426
598
  )
427
- order.price = order.get_price()
428
- order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
429
- # if we cannot automatically find a price, we consider the stock is invalid and we sell it
430
- if not order.price:
431
- order.price = Decimal("0.0")
432
- order.weighting = -order_dto.effective_weight
433
-
434
- 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
+ )
435
627
  # final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
436
- self.get_orders().filter(target_weight=0, effective_weight=0).delete()
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()
437
635
 
438
- def approve_workflow(
636
+ def apply_workflow(
439
637
  self,
440
- approve_automatically: bool = True,
638
+ apply_automatically: bool = True,
441
639
  silent_exception: bool = False,
442
640
  force_reset_order: bool = False,
443
641
  **reset_order_kwargs,
444
642
  ):
445
- 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:
446
646
  logger.info("Reverting order proposal ...")
447
647
  self.revert()
448
648
  if self.status == OrderProposal.Status.DRAFT:
@@ -459,26 +659,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
459
659
  return
460
660
  logger.info("Submitting order proposal ...")
461
661
  self.submit()
462
- if self.status == OrderProposal.Status.SUBMIT:
463
- logger.info("Approving order proposal ...")
464
- if approve_automatically and self.portfolio.can_be_rebalanced:
465
- 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)
466
667
 
467
- 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
+ ):
468
675
  last_order_proposal = self
469
676
  last_order_proposal_created = False
470
- self.portfolio.load_builder_returns(self.trade_date, date.today())
471
- 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:
472
679
  last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
473
680
  if not last_order_proposal_created:
474
- if reapply_order_proposal:
681
+ if reapply_order_proposal or last_order_proposal.rebalancing_model:
475
682
  logger.info(f"Replaying order proposal {last_order_proposal}")
476
- 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
+ )
477
686
  last_order_proposal.save()
478
687
  else:
479
688
  logger.info(f"Resetting order proposal {last_order_proposal}")
480
- last_order_proposal.reset_orders()
481
- 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:
482
691
  break
483
692
  next_order_proposal = last_order_proposal.next_order_proposal
484
693
  if next_order_proposal:
@@ -517,8 +726,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
517
726
  else:
518
727
  last_order_proposal_created = False
519
728
  last_order_proposal = next_order_proposal
520
- if broadcast_changes_at_date:
521
- 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
+ )
522
732
 
523
733
  def invalidate_future_order_proposal(self):
524
734
  # Delete all future automatic order proposals and set the manual one into a draft state
@@ -526,7 +736,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
526
736
  trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
527
737
  ).delete()
528
738
  for future_order_proposal in self.portfolio.order_proposals.filter(
529
- trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
739
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
530
740
  ):
531
741
  future_order_proposal.revert()
532
742
  future_order_proposal.save()
@@ -548,6 +758,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
548
758
  """
549
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
550
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
551
766
  # Calculate the order's total value in the portfolio's currency
552
767
  trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
553
768
 
@@ -585,9 +800,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
585
800
  # Retrieve orders with base information
586
801
  orders = self.get_orders()
587
802
  # Calculate the total target weight of all orders
588
- total_target_weight = orders.exclude(underlying_instrument__is_cash=True).aggregate(
589
- s=models.Sum("target_weight")
590
- )["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)
591
806
  if target_cash_weight is None:
592
807
  target_cash_weight = Decimal("1") - total_target_weight
593
808
 
@@ -628,7 +843,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
628
843
  @transition(
629
844
  field=status,
630
845
  source=Status.DRAFT,
631
- target=Status.SUBMIT,
846
+ target=Status.PENDING,
632
847
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
633
848
  user.profile, portfolio=instance.portfolio
634
849
  ),
@@ -647,13 +862,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
647
862
  def submit(self, by=None, description=None, **kwargs):
648
863
  orders = []
649
864
  orders_validation_warnings = []
650
- for order in self.get_orders():
865
+ qs = self.get_orders()
866
+ total_target_weight = Decimal("0")
867
+ for order in qs:
651
868
  order_warnings = order.submit(
652
869
  by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
653
870
  )
654
871
  if order_warnings:
655
872
  orders_validation_warnings.extend(order_warnings)
656
873
  orders.append(order)
874
+ total_target_weight += order._target_weight
657
875
 
658
876
  Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
659
877
 
@@ -663,6 +881,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
663
881
  estimated_cash_position._build_dto()
664
882
  )
665
883
  self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
884
+ self.total_cash_weight = Decimal("1") - total_target_weight
666
885
  return orders_validation_warnings
667
886
 
668
887
  def can_submit(self):
@@ -687,18 +906,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
687
906
  del self.__dict__["validated_trading_service"]
688
907
  return errors
689
908
 
690
- @property
691
- def can_be_approved_or_denied(self):
692
- return not self.has_non_successful_checks and self.portfolio.is_manageable
693
-
694
909
  @transition(
695
910
  field=status,
696
- source=Status.SUBMIT,
911
+ source=Status.PENDING,
697
912
  target=Status.APPROVED,
698
913
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
699
914
  user.profile, portfolio=instance.portfolio
700
915
  )
701
- and instance.can_be_approved_or_denied,
916
+ and not instance.has_non_successful_checks,
702
917
  custom={
703
918
  "_transition_button": ActionButton(
704
919
  method=RequestType.PATCH,
@@ -711,7 +926,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
711
926
  )
712
927
  },
713
928
  )
714
- 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):
715
988
  # We validate order which will create or update the initial asset positions
716
989
  if not self.portfolio.can_be_rebalanced:
717
990
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
@@ -722,39 +995,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
722
995
  for order in self.get_orders():
723
996
  with suppress(ValueError):
724
997
  # we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
725
- 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
+ ):
726
1002
  assets[order.underlying_instrument.id] = order._target_weight
727
1003
 
728
1004
  # if there is cash leftover, we create an extra asset position to hold the cash component
729
- if estimated_cash_position.weighting and len(assets) > 0:
1005
+ if estimated_cash_position.weighting > 0 and len(assets) > 0:
730
1006
  warnings.append(
731
1007
  f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
732
1008
  )
733
1009
  assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
734
-
735
1010
  self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
736
- force_save=True, is_estimated=False
1011
+ force_save=True, is_estimated=False, delete_leftovers=True
737
1012
  )
738
1013
  if replay and self.portfolio.is_manageable:
739
- 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
740
1019
  return warnings
741
1020
 
742
- def can_approve(self):
1021
+ def can_apply(self):
743
1022
  errors = dict()
744
1023
  orders = self.get_orders()
745
1024
  if not self.portfolio.can_be_rebalanced:
746
1025
  errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
747
1026
  if not orders.exists():
748
1027
  errors["non_field_errors"] = [
749
- _("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")
750
1029
  ]
751
1030
  if not self.portfolio.can_be_rebalanced:
752
1031
  errors["portfolio"] = [
753
- [_("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")]
754
1033
  ]
755
1034
  if self.has_non_successful_checks:
756
1035
  errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
757
- 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
+ ):
758
1039
  errors["non_field_errors"] = [
759
1040
  _("There is warning that needs to be addresses on the orders before approval.")
760
1041
  ]
@@ -762,40 +1043,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
762
1043
 
763
1044
  @transition(
764
1045
  field=status,
765
- source=Status.SUBMIT,
766
- target=Status.DENIED,
767
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
768
- user.profile, portfolio=instance.portfolio
769
- )
770
- and instance.can_be_approved_or_denied,
771
- custom={
772
- "_transition_button": ActionButton(
773
- method=RequestType.PATCH,
774
- identifiers=("wbportfolio:orderproposal",),
775
- icon=WBIcon.DENY.icon,
776
- key="deny",
777
- label="Deny",
778
- action_label="Deny",
779
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
780
- )
781
- },
782
- )
783
- def deny(self, by=None, description=None, **kwargs):
784
- self.orders.all().delete()
785
- with suppress(KeyError):
786
- del self.__dict__["validated_trading_service"]
787
-
788
- def can_deny(self):
789
- errors = dict()
790
- if not self.orders.all().exists():
791
- errors["non_field_errors"] = [
792
- _("At least one order needs to be submitted to be able to deny this proposal")
793
- ]
794
- return errors
795
-
796
- @transition(
797
- field=status,
798
- source=Status.SUBMIT,
1046
+ source=Status.PENDING,
799
1047
  target=Status.DRAFT,
800
1048
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
801
1049
  user.profile, portfolio=instance.portfolio
@@ -824,7 +1072,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
824
1072
 
825
1073
  @transition(
826
1074
  field=status,
827
- source=Status.APPROVED,
1075
+ source=Status.APPLIED,
828
1076
  target=Status.DRAFT,
829
1077
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
830
1078
  user.profile, portfolio=instance.portfolio
@@ -842,6 +1090,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
842
1090
  },
843
1091
  )
844
1092
  def revert(self, **kwargs):
1093
+ self.approver = None
845
1094
  with suppress(KeyError):
846
1095
  del self.__dict__["validated_trading_service"]
847
1096
  self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
@@ -856,6 +1105,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
856
1105
  ]
857
1106
  return errors
858
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
+
859
1186
  # End FSM logics
860
1187
 
861
1188
  @classmethod
@@ -881,7 +1208,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
881
1208
  if not raw and instance.status == OrderProposal.Status.FAILED:
882
1209
  # we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
883
1210
  instance.invalidate_future_order_proposal()
884
- instance.invalidate_future_order_proposal()
885
1211
 
886
1212
 
887
1213
  @shared_task(queue="portfolio")
@@ -898,3 +1224,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
898
1224
  reverse_name="wbportfolio:portfolio-detail",
899
1225
  reverse_args=[order_proposal.portfolio.id],
900
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
+ )