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