wbportfolio 1.54.13__py2.py3-none-any.whl → 1.54.15__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 (86) hide show
  1. wbportfolio/admin/__init__.py +2 -0
  2. wbportfolio/admin/orders/__init__.py +2 -0
  3. wbportfolio/admin/orders/order_proposals.py +14 -0
  4. wbportfolio/admin/orders/orders.py +30 -0
  5. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  6. wbportfolio/admin/transactions/__init__.py +0 -1
  7. wbportfolio/admin/transactions/trades.py +2 -17
  8. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  9. wbportfolio/factories/__init__.py +2 -1
  10. wbportfolio/factories/orders/__init__.py +2 -0
  11. wbportfolio/factories/orders/order_proposals.py +17 -0
  12. wbportfolio/factories/orders/orders.py +21 -0
  13. wbportfolio/factories/rebalancing.py +1 -1
  14. wbportfolio/factories/trades.py +2 -13
  15. wbportfolio/filters/orders/__init__.py +1 -0
  16. wbportfolio/filters/orders/orders.py +11 -0
  17. wbportfolio/import_export/handlers/trade.py +20 -20
  18. wbportfolio/import_export/resources/trades.py +2 -2
  19. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  20. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  21. wbportfolio/models/__init__.py +2 -0
  22. wbportfolio/models/orders/__init__.py +2 -0
  23. wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +289 -245
  24. wbportfolio/models/orders/orders.py +243 -0
  25. wbportfolio/models/portfolio.py +17 -20
  26. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
  27. wbportfolio/models/transactions/__init__.py +0 -2
  28. wbportfolio/models/transactions/trades.py +10 -450
  29. wbportfolio/pms/analytics/portfolio.py +10 -6
  30. wbportfolio/pms/analytics/utils.py +9 -0
  31. wbportfolio/pms/trading/handler.py +6 -4
  32. wbportfolio/pms/typing.py +18 -7
  33. wbportfolio/rebalancing/decorators.py +1 -1
  34. wbportfolio/rebalancing/models/composite.py +3 -7
  35. wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
  36. wbportfolio/serializers/__init__.py +1 -0
  37. wbportfolio/serializers/orders/__init__.py +2 -0
  38. wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +23 -15
  39. wbportfolio/serializers/orders/orders.py +187 -0
  40. wbportfolio/serializers/portfolios.py +7 -7
  41. wbportfolio/serializers/rebalancing.py +1 -1
  42. wbportfolio/serializers/transactions/__init__.py +1 -5
  43. wbportfolio/serializers/transactions/trades.py +1 -182
  44. wbportfolio/tests/conftest.py +4 -2
  45. wbportfolio/tests/models/orders/__init__.py +0 -0
  46. wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +218 -246
  47. wbportfolio/tests/models/test_portfolios.py +11 -10
  48. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  49. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  50. wbportfolio/tests/rebalancing/test_models.py +24 -28
  51. wbportfolio/tests/signals.py +10 -10
  52. wbportfolio/tests/tests.py +1 -1
  53. wbportfolio/urls.py +7 -7
  54. wbportfolio/viewsets/__init__.py +2 -0
  55. wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
  56. wbportfolio/viewsets/configs/buttons/trades.py +0 -8
  57. wbportfolio/viewsets/configs/display/__init__.py +0 -2
  58. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  59. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  60. wbportfolio/viewsets/configs/display/trades.py +1 -225
  61. wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
  62. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  63. wbportfolio/viewsets/orders/__init__.py +6 -0
  64. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  65. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  66. wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +22 -21
  67. wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
  68. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  69. wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
  70. wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
  71. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  72. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  73. wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
  74. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  75. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  76. wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -45
  77. wbportfolio/viewsets/orders/orders.py +219 -0
  78. wbportfolio/viewsets/portfolios.py +12 -12
  79. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  80. wbportfolio/viewsets/transactions/__init__.py +1 -7
  81. wbportfolio/viewsets/transactions/trades.py +1 -199
  82. {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/METADATA +1 -1
  83. {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/RECORD +85 -58
  84. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  85. {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/WHEEL +0 -0
  86. {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,14 @@ from typing import Any, TypeVar
8
8
  from celery import shared_task
9
9
  from django.core.exceptions import ValidationError
10
10
  from django.db import DatabaseError, models
11
+ from django.db.models import (
12
+ F,
13
+ OuterRef,
14
+ Subquery,
15
+ Sum,
16
+ Value,
17
+ )
18
+ from django.db.models.functions import Coalesce, Round
11
19
  from django.db.models.signals import post_save
12
20
  from django.dispatch import receiver
13
21
  from django.utils.functional import cached_property
@@ -26,60 +34,60 @@ from wbcore.utils.models import CloneMixin
26
34
  from wbfdm.models import InstrumentPrice
27
35
  from wbfdm.models.instruments.instruments import Cash, Instrument
28
36
 
37
+ from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
38
+ from wbportfolio.models.exceptions import InvalidAnalyticPortfolio
29
39
  from wbportfolio.models.roles import PortfolioRole
30
40
  from wbportfolio.pms.trading import TradingService
31
41
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
32
42
  from wbportfolio.pms.typing import Position as PositionDTO
33
43
 
34
- from ..asset import AssetPosition, AssetPositionIterator
35
- from ..exceptions import InvalidAnalyticPortfolio
36
- from .trades import Trade
44
+ from .orders import Order
37
45
 
38
46
  logger = logging.getLogger("pms")
39
47
 
40
- SelfTradeProposal = TypeVar("SelfTradeProposal", bound="TradeProposal")
48
+ SelfOrderProposal = TypeVar("SelfOrderProposal", bound="OrderProposal")
41
49
 
42
50
 
43
- class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
51
+ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
44
52
  trade_date = models.DateField(verbose_name="Trading Date")
45
53
 
46
54
  class Status(models.TextChoices):
47
55
  DRAFT = "DRAFT", "Draft"
48
- SUBMIT = "SUBMIT", "Submit"
56
+ SUBMIT = "SUBMIT", "Pending"
49
57
  APPROVED = "APPROVED", "Approved"
50
58
  DENIED = "DENIED", "Denied"
51
59
  FAILED = "FAILED", "Failed"
52
60
 
53
- comment = models.TextField(default="", verbose_name="Trade Comment", blank=True)
61
+ comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
54
62
  status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name="Status")
55
63
  rebalancing_model = models.ForeignKey(
56
64
  "wbportfolio.RebalancingModel",
57
65
  on_delete=models.SET_NULL,
58
66
  blank=True,
59
67
  null=True,
60
- related_name="trade_proposals",
68
+ related_name="order_proposals",
61
69
  verbose_name="Rebalancing Model",
62
70
  help_text="Rebalancing Model that generates the target portfolio",
63
71
  )
64
72
  portfolio = models.ForeignKey(
65
- "wbportfolio.Portfolio", related_name="trade_proposals", on_delete=models.PROTECT, verbose_name="Portfolio"
73
+ "wbportfolio.Portfolio", related_name="order_proposals", on_delete=models.PROTECT, verbose_name="Portfolio"
66
74
  )
67
75
  creator = models.ForeignKey(
68
76
  "directory.Person",
69
77
  blank=True,
70
78
  null=True,
71
- related_name="trade_proposals",
79
+ related_name="order_proposals",
72
80
  on_delete=models.PROTECT,
73
81
  verbose_name="Owner",
74
82
  )
75
83
 
76
84
  class Meta:
77
- verbose_name = "Trade Proposal"
78
- verbose_name_plural = "Trade Proposals"
85
+ verbose_name = "Order Proposal"
86
+ verbose_name_plural = "Order Proposals"
79
87
  constraints = [
80
88
  models.UniqueConstraint(
81
89
  fields=["portfolio", "trade_date"],
82
- name="unique_trade_proposal",
90
+ name="unique_order_proposal",
83
91
  ),
84
92
  ]
85
93
 
@@ -87,8 +95,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
87
95
  if not self.trade_date and self.portfolio.assets.exists():
88
96
  self.trade_date = (self.portfolio.assets.latest("date").date + BDay(1)).date()
89
97
 
90
- # if a trade proposal is created before the existing earliest trade proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
91
- if not self.portfolio.trade_proposals.filter(trade_date__lt=self.trade_date).exists():
98
+ # 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
99
+ if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
92
100
  new_inception_date = (self.trade_date + BDay(1)).date()
93
101
  self.portfolio.instruments.filter(inception_date__gt=new_inception_date).update(
94
102
  inception_date=new_inception_date
@@ -133,38 +141,87 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
133
141
  return (self.trade_date - BDay(1)).date()
134
142
 
135
143
  @property
136
- def previous_trade_proposal(self) -> SelfTradeProposal | None:
137
- future_proposals = TradeProposal.objects.filter(portfolio=self.portfolio).filter(
138
- trade_date__lt=self.trade_date, status=TradeProposal.Status.APPROVED
144
+ def previous_order_proposal(self) -> SelfOrderProposal | None:
145
+ future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
146
+ trade_date__lt=self.trade_date, status=OrderProposal.Status.APPROVED
139
147
  )
140
148
  if future_proposals.exists():
141
149
  return future_proposals.latest("trade_date")
142
150
  return None
143
151
 
144
152
  @property
145
- def next_trade_proposal(self) -> SelfTradeProposal | None:
146
- future_proposals = TradeProposal.objects.filter(portfolio=self.portfolio).filter(
147
- trade_date__gt=self.trade_date, status=TradeProposal.Status.APPROVED
153
+ def next_order_proposal(self) -> SelfOrderProposal | None:
154
+ future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
155
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
148
156
  )
149
157
  if future_proposals.exists():
150
158
  return future_proposals.earliest("trade_date")
151
159
  return None
152
160
 
153
- @property
154
- def base_assets(self) -> dict[int, Decimal]:
155
- """
156
- Return a dictionary representation (instrument_id: target weight) of this trade proposal
157
- Returns:
158
- A dictionary representation
159
-
160
- """
161
- return {
162
- v["underlying_instrument"]: v["target_weight"]
163
- for v in self.trades.all()
164
- .annotate_base_info()
165
- .filter(status=Trade.Status.EXECUTED)
166
- .values("underlying_instrument", "target_weight")
167
- }
161
+ def get_orders(self):
162
+ base_qs = self.orders.all().annotate(
163
+ last_effective_date=Subquery(
164
+ AssetPosition.unannotated_objects.filter(
165
+ date__lt=OuterRef("value_date"),
166
+ portfolio=OuterRef("portfolio"),
167
+ )
168
+ .order_by("-date")
169
+ .values("date")[:1]
170
+ ),
171
+ previous_weight=Coalesce(
172
+ Subquery(
173
+ AssetPosition.unannotated_objects.filter(
174
+ underlying_quote=OuterRef("underlying_instrument"),
175
+ date=OuterRef("last_effective_date"),
176
+ portfolio=OuterRef("portfolio"),
177
+ )
178
+ .values("portfolio")
179
+ .annotate(s=Sum("weighting"))
180
+ .values("s")[:1]
181
+ ),
182
+ Decimal(0),
183
+ ),
184
+ contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
185
+ )
186
+ portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
187
+ orders = base_qs.annotate(
188
+ effective_weight=Round(
189
+ F("contribution") / Value(portfolio_contribution), precision=Order.ORDER_WEIGHTING_PRECISION
190
+ ),
191
+ target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
192
+ effective_shares=Coalesce(
193
+ Subquery(
194
+ AssetPosition.objects.filter(
195
+ underlying_quote=OuterRef("underlying_instrument"),
196
+ date=OuterRef("last_effective_date"),
197
+ portfolio=OuterRef("portfolio"),
198
+ )
199
+ .values("portfolio")
200
+ .annotate(s=Sum("shares"))
201
+ .values("s")[:1]
202
+ ),
203
+ Decimal(0),
204
+ ),
205
+ target_shares=F("effective_shares") + F("shares"),
206
+ )
207
+ total_effective_weight = orders.aggregate(s=models.Sum("effective_weight"))["s"] or Decimal("1")
208
+ with suppress(Order.DoesNotExist):
209
+ largest_order = orders.latest("weighting")
210
+ if quant_error := Decimal("1") - total_effective_weight:
211
+ orders = orders.annotate(
212
+ effective_weight=models.Case(
213
+ models.When(
214
+ id=largest_order.id, then=models.F("effective_weight") + models.Value(Decimal(quant_error))
215
+ ),
216
+ default=models.F("effective_weight"),
217
+ ),
218
+ target_weight=models.F("effective_weight") + models.F("weighting"),
219
+ )
220
+ return orders.annotate(
221
+ has_warnings=models.Case(
222
+ models.When(models.Q(price=0) | models.Q(target_weight__lt=0), then=Value(True)), default=Value(False)
223
+ ),
224
+ )
168
225
 
169
226
  def __str__(self) -> str:
170
227
  return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
@@ -173,7 +230,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
173
230
  """
174
231
  Data Transfer Object
175
232
  Returns:
176
- DTO trade object
233
+ DTO order object
177
234
  """
178
235
  portfolio = {}
179
236
  for asset in self.portfolio.assets.filter(date=self.last_effective_date):
@@ -184,37 +241,42 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
184
241
  price=asset._price,
185
242
  currency_fx_rate=asset._currency_fx_rate,
186
243
  )
187
- for trade in self.trades.all().annotate_base_info():
188
- portfolio[trade.underlying_instrument] = dict(
189
- weighting=trade._previous_weight,
190
- delta_weight=trade.weighting,
191
- shares=trade._target_shares if not use_effective else trade._effective_shares,
192
- price=trade.price,
193
- currency_fx_rate=trade.currency_fx_rate,
244
+ for order in self.get_orders():
245
+ portfolio[order.underlying_instrument] = dict(
246
+ weighting=order._previous_weight,
247
+ delta_weight=order.weighting,
248
+ shares=order._target_shares if not use_effective else order._effective_shares,
249
+ price=order.price,
250
+ currency_fx_rate=order.currency_fx_rate,
194
251
  )
195
-
196
252
  previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
197
253
  try:
198
- drifted_weights = self.portfolio.get_analytic_portfolio(
254
+ last_returns, portfolio_contribution = self.portfolio.get_analytic_portfolio(
199
255
  self.value_date, weights=previous_weights, use_dl=True
200
- ).get_next_weights()
256
+ ).get_contributions()
257
+ last_returns = last_returns.to_dict()
201
258
  except InvalidAnalyticPortfolio:
202
- drifted_weights = {}
259
+ last_returns, portfolio_contribution = {}, 1
203
260
  positions = []
204
261
  for instrument, row in portfolio.items():
205
262
  weighting = row["weighting"]
206
- try:
207
- drift_factor = Decimal(drifted_weights.pop(instrument.id)) / weighting if weighting else Decimal("1")
208
- except KeyError:
209
- drift_factor = Decimal("1")
263
+ daily_return = Decimal(last_returns.get(instrument.id, 0))
210
264
  if not use_effective:
211
- weighting = weighting * drift_factor + row["delta_weight"]
265
+ drifted_weight = (
266
+ round(
267
+ weighting * (daily_return + Decimal("1")) / Decimal(portfolio_contribution),
268
+ Order.ORDER_WEIGHTING_PRECISION,
269
+ )
270
+ if portfolio_contribution
271
+ else weighting
272
+ )
273
+ weighting = drifted_weight + row["delta_weight"]
212
274
  positions.append(
213
275
  PositionDTO(
214
276
  underlying_instrument=instrument.id,
215
277
  instrument_type=instrument.instrument_type.id,
216
278
  weighting=weighting,
217
- drift_factor=drift_factor if use_effective else Decimal("1"),
279
+ daily_return=daily_return if use_effective else Decimal("0"),
218
280
  shares=row["shares"],
219
281
  currency=instrument.currency.id,
220
282
  date=self.last_effective_date if use_effective else self.trade_date,
@@ -226,39 +288,39 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
226
288
  return PortfolioDTO(positions)
227
289
 
228
290
  # Start tools methods
229
- def _clone(self, **kwargs) -> SelfTradeProposal:
291
+ def _clone(self, **kwargs) -> SelfOrderProposal:
230
292
  """
231
- Method to clone self as a new trade proposal. It will automatically shift the trade date if a proposal already exists
293
+ Method to clone self as a new order proposal. It will automatically shift the order date if a proposal already exists
232
294
  Args:
233
295
  **kwargs: The keyword arguments
234
296
  Returns:
235
- The cloned trade proposal
297
+ The cloned order proposal
236
298
  """
237
299
  trade_date = kwargs.get("clone_date", self.trade_date)
238
300
 
239
- # Find the next valid trade date
240
- while TradeProposal.objects.filter(portfolio=self.portfolio, trade_date=trade_date).exists():
301
+ # Find the next valid order date
302
+ while OrderProposal.objects.filter(portfolio=self.portfolio, trade_date=trade_date).exists():
241
303
  trade_date += timedelta(days=1)
242
304
 
243
- trade_proposal_clone = TradeProposal.objects.create(
305
+ order_proposal_clone = OrderProposal.objects.create(
244
306
  trade_date=trade_date,
245
307
  comment=kwargs.get("clone_comment", self.comment),
246
- status=TradeProposal.Status.DRAFT,
308
+ status=OrderProposal.Status.DRAFT,
247
309
  rebalancing_model=self.rebalancing_model,
248
310
  portfolio=self.portfolio,
249
311
  creator=self.creator,
250
312
  )
251
- for trade in self.trades.all():
252
- trade.id = None
253
- trade.trade_proposal = trade_proposal_clone
254
- trade.save()
313
+ for order in self.orders.all():
314
+ order.id = None
315
+ order.order_proposal = order_proposal_clone
316
+ order.save()
255
317
 
256
- return trade_proposal_clone
318
+ return order_proposal_clone
257
319
 
258
- def normalize_trades(self, total_target_weight: Decimal = Decimal("1.0")):
320
+ def normalize_orders(self, total_target_weight: Decimal = Decimal("1.0")):
259
321
  """
260
- Call the trading service with the existing trades and normalize them in order to obtain a total sum target weight of 100%
261
- The existing trade will be modified directly with the given normalization factor
322
+ Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
323
+ The existing order will be modified directly with the given normalization factor
262
324
  """
263
325
  service = TradingService(
264
326
  self.trade_date,
@@ -266,22 +328,20 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
266
328
  target_portfolio=self.convert_to_portfolio(),
267
329
  total_target_weight=total_target_weight,
268
330
  )
269
- leftovers_trades = self.trades.all()
270
- for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
271
- with suppress(Trade.DoesNotExist):
272
- trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
273
- trade.weighting = round(trade_dto.delta_weight, Trade.TRADE_WEIGHTING_PRECISION)
274
- trade.save()
275
- leftovers_trades = leftovers_trades.exclude(id=trade.id)
276
- leftovers_trades.delete()
277
- t_weight = self.trades.all().annotate_base_info().aggregate(models.Sum("target_weight"))[
278
- "target_weight__sum"
279
- ] or Decimal("0.0")
280
- # we handle quantization error due to the decimal max digits. In that case, we take the biggest trade (highest weight) and we remove the quantization error
331
+ leftovers_orders = self.orders.all()
332
+ for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
333
+ with suppress(Order.DoesNotExist):
334
+ order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
335
+ order.weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
336
+ order.save()
337
+ leftovers_orders = leftovers_orders.exclude(id=order.id)
338
+ leftovers_orders.delete()
339
+ t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
340
+ # 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
281
341
  if quantize_error := (t_weight - total_target_weight):
282
- biggest_trade = self.trades.latest("weighting")
283
- biggest_trade.weighting -= quantize_error
284
- biggest_trade.save()
342
+ biggest_order = self.orders.latest("weighting")
343
+ biggest_order.weighting -= quantize_error
344
+ biggest_order.save()
285
345
 
286
346
  def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
287
347
  if self.rebalancing_model:
@@ -292,33 +352,29 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
292
352
  return self.rebalancing_model.get_target_portfolio(
293
353
  self.portfolio, self.trade_date, self.value_date, **params
294
354
  )
295
- if self.trades.exists():
296
- return self.convert_to_portfolio()
297
- # Return the current portfolio by default
298
- return self.convert_to_portfolio(use_effective=False)
355
+ return self.convert_to_portfolio()
299
356
 
300
357
  def _get_default_effective_portfolio(self):
301
358
  return self.convert_to_portfolio(use_effective=True)
302
359
 
303
- def reset_trades(
360
+ def reset_orders(
304
361
  self,
305
362
  target_portfolio: PortfolioDTO | None = None,
306
363
  effective_portfolio: PortfolioRole | None = None,
307
- validate_trade: bool = True,
364
+ validate_order: bool = True,
308
365
  total_target_weight: Decimal = Decimal("1.0"),
309
366
  ):
310
367
  """
311
- Will delete all existing trades and recreate them from the method `create_or_update_trades`
368
+ Will delete all existing orders and recreate them from the method `create_or_update_trades`
312
369
  """
313
370
  if self.rebalancing_model:
314
- self.trades.all().delete()
315
- # delete all existing trades
371
+ self.orders.all().delete()
372
+ # delete all existing orders
316
373
  # Get effective and target portfolio
317
374
  if not target_portfolio:
318
375
  target_portfolio = self._get_default_target_portfolio()
319
376
  if not effective_portfolio:
320
377
  effective_portfolio = self._get_default_effective_portfolio()
321
-
322
378
  if target_portfolio:
323
379
  service = TradingService(
324
380
  self.trade_date,
@@ -326,98 +382,94 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
326
382
  target_portfolio=target_portfolio,
327
383
  total_target_weight=total_target_weight,
328
384
  )
329
- if validate_trade:
385
+ if validate_order:
330
386
  service.is_valid()
331
- trades = service.validated_trades
387
+ orders = service.validated_trades
332
388
  else:
333
- trades = service.trades_batch.trades_map.values()
334
- for trade_dto in trades:
335
- instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
336
- if not instrument.is_cash: # we do not save trade that includes cash component
389
+ orders = service.trades_batch.trades_map.values()
390
+ for order_dto in orders:
391
+ instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
392
+ if not instrument.is_cash: # we do not save order that includes cash component
337
393
  currency_fx_rate = instrument.currency.convert(
338
394
  self.value_date, self.portfolio.currency, exact_lookup=True
339
395
  )
340
- # we cannot do a bulk-create because Trade is a multi table inheritance
341
- weighting = round(trade_dto.delta_weight, Trade.TRADE_WEIGHTING_PRECISION)
342
- drift_factor = trade_dto.drift_factor
396
+ # we cannot do a bulk-create because Order is a multi table inheritance
397
+ weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
398
+ daily_return = order_dto.daily_return
343
399
  try:
344
- trade = self.trades.get(underlying_instrument=instrument)
345
- trade.weighting = weighting
346
- trade.currency_fx_rate = currency_fx_rate
347
- trade.status = Trade.Status.DRAFT
348
- trade.drift_factor = drift_factor
349
- except Trade.DoesNotExist:
350
- trade = Trade(
400
+ order = self.orders.get(underlying_instrument=instrument)
401
+ order.weighting = weighting
402
+ order.currency_fx_rate = currency_fx_rate
403
+ order.daily_return = daily_return
404
+ except Order.DoesNotExist:
405
+ order = Order(
351
406
  underlying_instrument=instrument,
352
- currency=instrument.currency,
353
- value_date=self.value_date,
354
- transaction_date=self.trade_date,
355
- trade_proposal=self,
356
- portfolio=self.portfolio,
407
+ order_proposal=self,
408
+ value_date=self.trade_date,
357
409
  weighting=weighting,
358
- drift_factor=drift_factor,
359
- status=Trade.Status.DRAFT,
410
+ daily_return=daily_return,
360
411
  currency_fx_rate=currency_fx_rate,
361
412
  )
362
- trade.price = trade.get_price()
413
+ order.price = order.get_price()
414
+ order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
363
415
  # if we cannot automatically find a price, we consider the stock is invalid and we sell it
364
- if not trade.price:
365
- trade.price = Decimal("0.0")
366
- trade.weighting = -trade_dto.effective_weight
416
+ if not order.price:
417
+ order.price = Decimal("0.0")
418
+ order.weighting = -order_dto.effective_weight
367
419
 
368
- trade.save()
369
- # final sanity check to make sure invalid trade with effective and target weight of 0 are automatically removed:
370
- self.trades.all().annotate_base_info().filter(target_weight=0, effective_weight=0).delete()
420
+ order.save()
421
+ # final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
422
+ self.get_orders().filter(target_weight=0, effective_weight=0).delete()
371
423
 
372
424
  def approve_workflow(
373
425
  self,
374
426
  approve_automatically: bool = True,
375
427
  silent_exception: bool = False,
376
- force_reset_trade: bool = False,
428
+ force_reset_order: bool = False,
377
429
  broadcast_changes_at_date: bool = True,
378
- **reset_trades_kwargs,
430
+ **reset_order_kwargs,
379
431
  ):
380
- if self.status == TradeProposal.Status.APPROVED:
381
- logger.info("Reverting trade proposal ...")
432
+ if self.status == OrderProposal.Status.APPROVED:
433
+ logger.info("Reverting order proposal ...")
382
434
  self.revert()
383
- if self.status == TradeProposal.Status.DRAFT:
435
+ if self.status == OrderProposal.Status.DRAFT:
384
436
  if (
385
- self.rebalancing_model or force_reset_trade
386
- ): # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
387
- logger.info("Resetting trades ...")
388
- try: # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
389
- self.reset_trades(**reset_trades_kwargs)
437
+ self.rebalancing_model or force_reset_order
438
+ ): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
439
+ logger.info("Resetting orders ...")
440
+ try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
441
+ self.reset_orders(**reset_order_kwargs)
390
442
  except (ValidationError, DatabaseError) as e:
391
- self.status = TradeProposal.Status.FAILED
443
+ self.status = OrderProposal.Status.FAILED
392
444
  if not silent_exception:
393
445
  raise ValidationError(e)
394
446
  return
395
- logger.info("Submitting trade proposal ...")
447
+ logger.info("Submitting order proposal ...")
396
448
  self.submit()
397
- if self.status == TradeProposal.Status.SUBMIT:
398
- logger.info("Approving trade proposal ...")
449
+ if self.status == OrderProposal.Status.SUBMIT:
450
+ logger.info("Approving order proposal ...")
399
451
  if approve_automatically and self.portfolio.can_be_rebalanced:
400
452
  self.approve(replay=False, broadcast_changes_at_date=broadcast_changes_at_date)
401
453
 
402
454
  def replay(self, broadcast_changes_at_date: bool = True):
403
- last_trade_proposal = self
404
- last_trade_proposal_created = False
405
- while last_trade_proposal and last_trade_proposal.status == TradeProposal.Status.APPROVED:
406
- if not last_trade_proposal_created:
407
- logger.info(f"Replaying trade proposal {last_trade_proposal}")
408
- last_trade_proposal.approve_workflow(
455
+ last_order_proposal = self
456
+ last_order_proposal_created = False
457
+ while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPROVED:
458
+ if not last_order_proposal_created:
459
+ logger.info(f"Replaying order proposal {last_order_proposal}")
460
+ last_order_proposal.approve_workflow(
409
461
  silent_exception=True,
410
- force_reset_trade=True,
462
+ force_reset_order=True,
411
463
  broadcast_changes_at_date=broadcast_changes_at_date,
412
464
  )
413
- last_trade_proposal.save()
414
- if last_trade_proposal.status != TradeProposal.Status.APPROVED:
465
+ last_order_proposal.save()
466
+ if last_order_proposal.status != OrderProposal.Status.APPROVED:
415
467
  break
416
- next_trade_proposal = last_trade_proposal.next_trade_proposal
417
- if next_trade_proposal:
418
- next_trade_date = next_trade_proposal.trade_date - timedelta(days=1)
468
+ next_order_proposal = last_order_proposal.next_order_proposal
469
+ if next_order_proposal:
470
+ next_trade_date = next_order_proposal.trade_date - timedelta(days=1)
419
471
  elif next_expected_rebalancing_date := self.portfolio.get_next_rebalancing_date(
420
- last_trade_proposal.trade_date
472
+ last_order_proposal.trade_date
421
473
  ):
422
474
  next_trade_date = (
423
475
  next_expected_rebalancing_date + timedelta(days=7)
@@ -425,15 +477,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
425
477
  else:
426
478
  next_trade_date = date.today()
427
479
  next_trade_date = min(next_trade_date, date.today())
428
- positions, overriding_trade_proposal = self.portfolio.drift_weights(
429
- last_trade_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
480
+ positions, overriding_order_proposal = self.portfolio.drift_weights(
481
+ last_order_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
430
482
  )
431
483
 
432
484
  # self.portfolio.assets.filter(
433
- # date__gt=last_trade_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
485
+ # date__gt=last_order_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
434
486
  # ).update(
435
487
  # is_estimated=True
436
- # ) # ensure that we reset non estimated position leftover to estimated between trade proposal during replay
488
+ # ) # ensure that we reset non estimated position leftover to estimated between order proposal during replay
437
489
  self.portfolio.bulk_create_positions(
438
490
  positions,
439
491
  delete_leftovers=True,
@@ -441,49 +493,49 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
441
493
  broadcast_changes_at_date=broadcast_changes_at_date,
442
494
  evaluate_rebalancer=False,
443
495
  )
444
- for draft_tp in TradeProposal.objects.filter(
496
+ for draft_tp in OrderProposal.objects.filter(
445
497
  portfolio=self.portfolio,
446
- trade_date__gt=last_trade_proposal.trade_date,
498
+ trade_date__gt=last_order_proposal.trade_date,
447
499
  trade_date__lte=next_trade_date,
448
- status=TradeProposal.Status.DRAFT,
500
+ status=OrderProposal.Status.DRAFT,
449
501
  ):
450
- draft_tp.reset_trades()
451
- if overriding_trade_proposal:
452
- last_trade_proposal_created = True
453
- last_trade_proposal = overriding_trade_proposal
502
+ draft_tp.reset_orders()
503
+ if overriding_order_proposal:
504
+ last_order_proposal_created = True
505
+ last_order_proposal = overriding_order_proposal
454
506
  else:
455
- last_trade_proposal_created = False
456
- last_trade_proposal = next_trade_proposal
507
+ last_order_proposal_created = False
508
+ last_order_proposal = next_order_proposal
457
509
 
458
- def invalidate_future_trade_proposal(self):
459
- # Delete all future automatic trade proposals and set the manual one into a draft state
460
- self.portfolio.trade_proposals.filter(
510
+ def invalidate_future_order_proposal(self):
511
+ # Delete all future automatic order proposals and set the manual one into a draft state
512
+ self.portfolio.order_proposals.filter(
461
513
  trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
462
514
  ).delete()
463
- for future_trade_proposal in self.portfolio.trade_proposals.filter(
464
- trade_date__gt=self.trade_date, status=TradeProposal.Status.APPROVED
515
+ for future_order_proposal in self.portfolio.order_proposals.filter(
516
+ trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
465
517
  ):
466
- future_trade_proposal.revert()
467
- future_trade_proposal.save()
518
+ future_order_proposal.revert()
519
+ future_order_proposal.save()
468
520
 
469
521
  def get_estimated_shares(
470
522
  self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal
471
523
  ) -> Decimal | None:
472
524
  """
473
- Estimates the number of shares for a trade based on the given weight and underlying quote.
525
+ Estimates the number of shares for a order based on the given weight and underlying quote.
474
526
 
475
- This method calculates the estimated shares by dividing the trade's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
527
+ This method calculates the estimated shares by dividing the order's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
476
528
 
477
529
  Args:
478
- weight (Decimal): The weight of the trade.
479
- underlying_quote (Instrument): The underlying instrument for the trade.
530
+ weight (Decimal): The weight of the order.
531
+ underlying_quote (Instrument): The underlying instrument for the order.
480
532
 
481
533
  Returns:
482
534
  Decimal | None: The estimated number of shares or None if the calculation fails.
483
535
  """
484
- # Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
536
+ # 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
485
537
 
486
- # Calculate the trade's total value in the portfolio's currency
538
+ # Calculate the order's total value in the portfolio's currency
487
539
  trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
488
540
 
489
541
  # Convert the quote price to the portfolio's currency
@@ -507,9 +559,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
507
559
 
508
560
  def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
509
561
  """
510
- Estimates the target cash weight and shares for a trade proposal.
562
+ Estimates the target cash weight and shares for a order proposal.
511
563
 
512
- This method calculates the target cash weight by summing the weights of cash trades and adding any leftover weight from non-cash trades. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
564
+ This method calculates the target cash weight by summing the weights of cash orders and adding any leftover weight from non-cash orders. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
513
565
 
514
566
  Args:
515
567
  currency (Currency): The currency for the target currency component
@@ -517,17 +569,17 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
517
569
  Returns:
518
570
  tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
519
571
  """
520
- # Retrieve trades with base information
521
- trades = self.trades.all().annotate_base_info()
572
+ # Retrieve orders with base information
573
+ orders = self.get_orders()
522
574
 
523
- # Calculate the target cash weight from cash trades
524
- target_cash_weight = trades.filter(
575
+ # Calculate the target cash weight from cash orders
576
+ target_cash_weight = orders.filter(
525
577
  underlying_instrument__is_cash=True, underlying_instrument__currency=currency
526
578
  ).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
527
579
  # if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
528
580
  if currency == self.portfolio.currency:
529
- # Calculate the total target weight of all trades
530
- total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
581
+ # Calculate the total target weight of all orders
582
+ total_target_weight = orders.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
531
583
 
532
584
  # Add any leftover weight as cash
533
585
  target_cash_weight += Decimal(1) - total_target_weight
@@ -582,7 +634,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
582
634
  custom={
583
635
  "_transition_button": ActionButton(
584
636
  method=RequestType.PATCH,
585
- identifiers=("wbportfolio:tradeproposal",),
637
+ identifiers=("wbportfolio:orderproposal",),
586
638
  icon=WBIcon.SEND.icon,
587
639
  key="submit",
588
640
  label="Submit",
@@ -592,40 +644,38 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
592
644
  },
593
645
  )
594
646
  def submit(self, by=None, description=None, **kwargs):
595
- trades = []
596
- trades_validation_warnings = []
597
- for trade in self.trades.all():
598
- trade_warnings = trade.submit(
647
+ orders = []
648
+ orders_validation_warnings = []
649
+ for order in self.get_orders():
650
+ order_warnings = order.submit(
599
651
  by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
600
652
  )
601
- if trade_warnings:
602
- trades_validation_warnings.extend(trade_warnings)
603
- trades.append(trade)
653
+ if order_warnings:
654
+ orders_validation_warnings.extend(order_warnings)
655
+ orders.append(order)
604
656
 
605
- Trade.objects.bulk_update(trades, ["status", "shares", "weighting"])
657
+ Order.objects.bulk_update(orders, ["shares", "weighting"])
606
658
 
607
- # If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
659
+ # If we estimate cash on this order proposal, we make sure to create the corresponding cash component
608
660
  estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
609
661
  target_portfolio = self.validated_trading_service.trades_batch.convert_to_portfolio(
610
662
  estimated_cash_position._build_dto()
611
663
  )
612
664
  self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
613
- return trades_validation_warnings
665
+ return orders_validation_warnings
614
666
 
615
667
  def can_submit(self):
616
668
  errors = dict()
617
669
  errors_list = []
618
- if self.trades.exists() and self.trades.exclude(status=Trade.Status.DRAFT).exists():
619
- errors_list.append(_("All trades need to be draft before submitting"))
620
670
  service = self.validated_trading_service
621
671
  try:
622
672
  service.is_valid(ignore_error=True)
623
673
  # if service.trades_batch.total_abs_delta_weight == 0:
624
674
  # errors_list.append(
625
- # "There is no change detected in this trade proposal. Please submit at last one valid trade"
675
+ # "There is no change detected in this order proposal. Please submit at last one valid order"
626
676
  # )
627
677
  if len(service.validated_trades) == 0:
628
- errors_list.append(_("There is no valid trade on this proposal"))
678
+ errors_list.append(_("There is no valid order on this proposal"))
629
679
  if service.errors:
630
680
  errors_list.extend(service.errors)
631
681
  if errors_list:
@@ -651,7 +701,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
651
701
  custom={
652
702
  "_transition_button": ActionButton(
653
703
  method=RequestType.PATCH,
654
- identifiers=("wbportfolio:tradeproposal",),
704
+ identifiers=("wbportfolio:orderproposal",),
655
705
  icon=WBIcon.APPROVE.icon,
656
706
  key="approve",
657
707
  label="Approve",
@@ -661,33 +711,28 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
661
711
  },
662
712
  )
663
713
  def approve(self, by=None, description=None, replay: bool = True, **kwargs):
664
- # We validate trade which will create or update the initial asset positions
714
+ # We validate order which will create or update the initial asset positions
665
715
  if not self.portfolio.can_be_rebalanced:
666
716
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
667
- trades = []
668
717
  assets = []
669
718
  warnings = []
670
- # We do not want to create the estimated cash position if there is not trades in the trade proposal (shouldn't be possible anyway)
719
+ # We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
671
720
  estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
672
-
673
- for trade in self.trades.all():
721
+ for order in self.get_orders():
674
722
  with suppress(ValueError):
675
- asset = trade.get_asset()
723
+ asset = order.get_asset()
676
724
  # we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
677
725
  if asset.underlying_quote != estimated_cash_position.underlying_quote:
678
726
  assets.append(asset)
679
- trade.status = Trade.Status.EXECUTED
680
- trades.append(trade)
681
727
 
682
728
  # if there is cash leftover, we create an extra asset position to hold the cash component
683
- if estimated_cash_position.weighting and len(trades) > 0:
729
+ if estimated_cash_position.weighting and len(assets) > 0:
684
730
  warnings.append(
685
731
  f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
686
732
  )
687
733
  estimated_cash_position.pre_save()
688
734
  assets.append(estimated_cash_position)
689
735
 
690
- Trade.objects.bulk_update(trades, ["status"])
691
736
  self.portfolio.bulk_create_positions(
692
737
  AssetPositionIterator(self.portfolio).add(assets, is_estimated=False),
693
738
  evaluate_rebalancer=False,
@@ -695,23 +740,28 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
695
740
  **kwargs,
696
741
  )
697
742
  if replay and self.portfolio.is_manageable:
698
- replay_as_task.delay(self.id, user_id=by.id if by else None)
743
+ replay_as_task.delay(self.id, user_id=by.id if by else None, broadcast_changes_at_date=False)
699
744
  return warnings
700
745
 
701
746
  def can_approve(self):
702
747
  errors = dict()
748
+ orders = self.get_orders()
703
749
  if not self.portfolio.can_be_rebalanced:
704
750
  errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
705
- if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
751
+ if not orders.exists():
706
752
  errors["non_field_errors"] = [
707
- _("At least one trade needs to be submitted to be able to approve this proposal")
753
+ _("At least one order needs to be submitted to be able to approve this proposal")
708
754
  ]
709
755
  if not self.portfolio.can_be_rebalanced:
710
756
  errors["portfolio"] = [
711
- [_("The portfolio needs to be a model portfolio in order to approve this trade proposal manually")]
757
+ [_("The portfolio needs to be a model portfolio in order to approve this order proposal manually")]
712
758
  ]
713
759
  if self.has_non_successful_checks:
714
- errors["non_field_errors"] = [_("The pre trades rules did not passed successfully")]
760
+ errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
761
+ if orders.filter(has_warnings=True):
762
+ errors["non_field_errors"] = [
763
+ _("There is warning that needs to be addresses on the orders before approval.")
764
+ ]
715
765
  return errors
716
766
 
717
767
  @transition(
@@ -725,7 +775,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
725
775
  custom={
726
776
  "_transition_button": ActionButton(
727
777
  method=RequestType.PATCH,
728
- identifiers=("wbportfolio:tradeproposal",),
778
+ identifiers=("wbportfolio:orderproposal",),
729
779
  icon=WBIcon.DENY.icon,
730
780
  key="deny",
731
781
  label="Deny",
@@ -735,15 +785,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
735
785
  },
736
786
  )
737
787
  def deny(self, by=None, description=None, **kwargs):
738
- self.trades.all().delete()
788
+ self.orders.all().delete()
739
789
  with suppress(KeyError):
740
790
  del self.__dict__["validated_trading_service"]
741
791
 
742
792
  def can_deny(self):
743
793
  errors = dict()
744
- if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
794
+ if not self.orders.all().exists():
745
795
  errors["non_field_errors"] = [
746
- _("At least one trade needs to be submitted to be able to deny this proposal")
796
+ _("At least one order needs to be submitted to be able to deny this proposal")
747
797
  ]
748
798
  return errors
749
799
 
@@ -759,7 +809,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
759
809
  custom={
760
810
  "_transition_button": ActionButton(
761
811
  method=RequestType.PATCH,
762
- identifiers=("wbportfolio:tradeproposal",),
812
+ identifiers=("wbportfolio:orderproposal",),
763
813
  icon=WBIcon.UNDO.icon,
764
814
  key="backtodraft",
765
815
  label="Back to Draft",
@@ -771,7 +821,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
771
821
  def backtodraft(self, **kwargs):
772
822
  with suppress(KeyError):
773
823
  del self.__dict__["validated_trading_service"]
774
- self.trades.update(status=Trade.Status.DRAFT)
775
824
  self.checks.delete()
776
825
 
777
826
  def can_backtodraft(self):
@@ -787,32 +836,27 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
787
836
  custom={
788
837
  "_transition_button": ActionButton(
789
838
  method=RequestType.PATCH,
790
- identifiers=("wbportfolio:tradeproposal",),
839
+ identifiers=("wbportfolio:orderproposal",),
791
840
  icon=WBIcon.REGENERATE.icon,
792
841
  key="revert",
793
842
  label="Revert",
794
843
  action_label="revert",
795
- description_fields="<p>Unapply trades and move everything back to draft (i.e. The underlying asset positions will change like the trades were never applied)</p>",
844
+ description_fields="<p>Unapply orders and move everything back to draft (i.e. The underlying asset positions will change like the orders were never applied)</p>",
796
845
  )
797
846
  },
798
847
  )
799
848
  def revert(self, **kwargs):
800
849
  with suppress(KeyError):
801
850
  del self.__dict__["validated_trading_service"]
802
- trades = []
803
851
  self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
804
852
  is_estimated=True
805
853
  ) # we delete the existing portfolio as it has been reverted
806
- for trade in self.trades.all():
807
- trade.status = Trade.Status.DRAFT
808
- trades.append(trade)
809
- Trade.objects.bulk_update(trades, ["status"])
810
854
 
811
855
  def can_revert(self):
812
856
  errors = dict()
813
857
  if not self.portfolio.can_be_rebalanced:
814
858
  errors["portfolio"] = [
815
- _("The portfolio needs to be a model portfolio in order to revert this trade proposal manually")
859
+ _("The portfolio needs to be a model portfolio in order to revert this order proposal manually")
816
860
  ]
817
861
  return errors
818
862
 
@@ -820,11 +864,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
820
864
 
821
865
  @classmethod
822
866
  def get_endpoint_basename(cls) -> str:
823
- return "wbportfolio:tradeproposal"
867
+ return "wbportfolio:orderproposal"
824
868
 
825
869
  @classmethod
826
870
  def get_representation_endpoint(cls) -> str:
827
- return "wbportfolio:tradeproposalrepresentation-list"
871
+ return "wbportfolio:orderproposalrepresentation-list"
828
872
 
829
873
  @classmethod
830
874
  def get_representation_value_key(cls) -> str:
@@ -835,26 +879,26 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
835
879
  return "{{_portfolio.name}} ({{trade_date}})"
836
880
 
837
881
 
838
- @receiver(post_save, sender="wbportfolio.TradeProposal")
839
- def post_fail_trade_proposal(sender, instance: TradeProposal, created, raw, **kwargs):
840
- # if we have a trade proposal in a fail state, we ensure that all future existing trade proposal are either deleted (automatic one) or set back to draft
841
- if not raw and instance.status == TradeProposal.Status.FAILED:
842
- # we delete all trade proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
843
- instance.invalidate_future_trade_proposal()
844
- instance.invalidate_future_trade_proposal()
882
+ @receiver(post_save, sender="wbportfolio.OrderProposal")
883
+ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kwargs):
884
+ # if we have a order proposal in a fail state, we ensure that all future existing order proposal are either deleted (automatic one) or set back to draft
885
+ if not raw and instance.status == OrderProposal.Status.FAILED:
886
+ # we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
887
+ instance.invalidate_future_order_proposal()
888
+ instance.invalidate_future_order_proposal()
845
889
 
846
890
 
847
891
  @shared_task(queue="portfolio")
848
- def replay_as_task(trade_proposal_id, user_id: int | None = None):
849
- trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
850
- trade_proposal.replay()
892
+ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
893
+ order_proposal = OrderProposal.objects.get(id=order_proposal_id)
894
+ order_proposal.replay(**kwargs)
851
895
  if user_id:
852
896
  user = User.objects.get(id=user_id)
853
897
  send_notification(
854
898
  code="wbportfolio.portfolio.replay_done",
855
- title="Trade Proposal Replay Completed",
856
- body=f'We’ve successfully replayed your trade proposal for "{trade_proposal.portfolio}" from {trade_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.',
899
+ title="Order Proposal Replay Completed",
900
+ body=f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.',
857
901
  user=user,
858
902
  reverse_name="wbportfolio:portfolio-detail",
859
- reverse_args=[trade_proposal.portfolio.id],
903
+ reverse_args=[order_proposal.portfolio.id],
860
904
  )