wbportfolio 1.48.0__py2.py3-none-any.whl → 1.49.0__py2.py3-none-any.whl

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

Potentially problematic release.


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

Files changed (33) hide show
  1. wbportfolio/factories/__init__.py +1 -3
  2. wbportfolio/factories/portfolios.py +0 -12
  3. wbportfolio/factories/product_groups.py +8 -1
  4. wbportfolio/factories/products.py +18 -0
  5. wbportfolio/factories/trades.py +5 -1
  6. wbportfolio/import_export/handlers/trade.py +8 -0
  7. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  8. wbportfolio/models/portfolio.py +8 -13
  9. wbportfolio/models/transactions/rebalancing.py +7 -1
  10. wbportfolio/models/transactions/trade_proposals.py +146 -49
  11. wbportfolio/models/transactions/trades.py +16 -11
  12. wbportfolio/pms/trading/handler.py +1 -1
  13. wbportfolio/pms/typing.py +3 -0
  14. wbportfolio/rebalancing/models/composite.py +14 -1
  15. wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
  16. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  17. wbportfolio/serializers/portfolios.py +26 -0
  18. wbportfolio/serializers/transactions/trades.py +13 -0
  19. wbportfolio/tests/models/test_portfolios.py +1 -1
  20. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  21. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  22. wbportfolio/tests/signals.py +1 -1
  23. wbportfolio/tests/viewsets/test_performances.py +2 -1
  24. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  25. wbportfolio/viewsets/configs/display/trades.py +23 -8
  26. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  27. wbportfolio/viewsets/portfolios.py +22 -7
  28. wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
  29. wbportfolio/viewsets/transactions/trades.py +86 -12
  30. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
  31. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +33 -31
  32. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
  33. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
@@ -9,18 +9,16 @@ from .claim import (
9
9
  from .custodians import CustodianFactory
10
10
  from .dividends import DividendTransactionsFactory
11
11
  from .fees import FeesFactory
12
- from wbfdm.factories.instrument_prices import InstrumentPriceFactory
13
12
  from .portfolios import (
14
13
  InstrumentPortfolioThroughModelFactory,
15
14
  ModelPortfolioFactory,
16
- ModelPortfolioWithBaseProductFactory,
17
15
  PortfolioFactory,
18
16
  )
19
17
  from .portfolio_swing_pricings import PortfolioSwingPricingFactory
20
18
  from .portfolio_cash_targets import PortfolioCashTargetFactory
21
19
  from .portfolio_cash_flow import DailyPortfolioCashFlowFactory
22
20
  from .product_groups import ProductGroupFactory, ProductGroupRepresentantFactory
23
- from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory
21
+ from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory, ModelPortfolioWithBaseProductFactory
24
22
  from .reconciliations import AccountReconciliationFactory, AccountReconciliationLineFactory
25
23
  from .roles import ManagerPortfolioRoleFactory, ProductPortfolioRoleFactory
26
24
  from .trades import CustomerTradeFactory, TradeFactory, TradeProposalFactory
@@ -9,8 +9,6 @@ from wbportfolio.models import (
9
9
  PortfolioPortfolioThroughModel,
10
10
  )
11
11
 
12
- from .products import ProductFactory
13
-
14
12
 
15
13
  class PortfolioFactory(factory.django.DjangoModelFactory):
16
14
  class Meta:
@@ -43,16 +41,6 @@ class ModelPortfolioFactory(PortfolioFactory):
43
41
  )
44
42
 
45
43
 
46
- class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
47
- @factory.post_generation
48
- def create_instrument(self, create, extracted, **kwargs):
49
- if create:
50
- instrument = ProductFactory.create()
51
- InstrumentPortfolioThroughModel.objects.update_or_create(
52
- instrument=instrument, defaults={"portfolio": self}
53
- )
54
-
55
-
56
44
  class InstrumentPortfolioThroughModelFactory(factory.django.DjangoModelFactory):
57
45
  instrument = factory.SubFactory("wbportfolio.factories.ProductFactory")
58
46
  portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
@@ -1,7 +1,8 @@
1
1
  import factory
2
2
  from wbfdm.factories.instruments import InstrumentFactory
3
3
 
4
- from wbportfolio.models import ProductGroup, ProductGroupRepresentant
4
+ from wbportfolio.factories import PortfolioFactory
5
+ from wbportfolio.models import InstrumentPortfolioThroughModel, ProductGroup, ProductGroupRepresentant
5
6
 
6
7
 
7
8
  class ProductGroupFactory(InstrumentFactory):
@@ -19,6 +20,12 @@ class ProductGroupFactory(InstrumentFactory):
19
20
  auditor = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
20
21
  paying_agent = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
21
22
 
23
+ @factory.post_generation
24
+ def create_initial_portfolio(product_group, *args, **kwargs):
25
+ if product_group.id and not product_group.portfolios.exists():
26
+ portfolio = PortfolioFactory.create()
27
+ InstrumentPortfolioThroughModel.objects.create(instrument=product_group, portfolio=portfolio)
28
+
22
29
 
23
30
  class ProductGroupRepresentantFactory(factory.django.DjangoModelFactory):
24
31
  class Meta:
@@ -4,6 +4,8 @@ import factory
4
4
  from wbcore.contrib.directory.factories.entries import CompanyFactory
5
5
  from wbfdm.factories.instruments import InstrumentFactory, InstrumentTypeFactory
6
6
 
7
+ from wbportfolio.factories import ModelPortfolioFactory, PortfolioFactory
8
+ from wbportfolio.models import InstrumentPortfolioThroughModel
7
9
  from wbportfolio.models.products import (
8
10
  AssetClass,
9
11
  InvestmentIndex,
@@ -37,6 +39,12 @@ class ProductFactory(InstrumentFactory):
37
39
  external_webpage = factory.Faker("url")
38
40
  instrument_type = factory.LazyAttribute(lambda o: InstrumentTypeFactory.create(name="Product", key="product"))
39
41
 
42
+ @factory.post_generation
43
+ def create_initial_portfolio(product, *args, **kwargs):
44
+ if product.id and not product.portfolios.exists():
45
+ portfolio = PortfolioFactory.create()
46
+ InstrumentPortfolioThroughModel.objects.create(instrument=product, portfolio=portfolio)
47
+
40
48
  # wbportfolio = factory.SubFactory(PortfolioFactory)
41
49
  # portfolio_computed = factory.SubFactory(PortfolioFactory)
42
50
 
@@ -55,3 +63,13 @@ class WhiteLabelProductFactory(ProductFactory):
55
63
 
56
64
  class IndexProductFactory(ProductFactory):
57
65
  pass
66
+
67
+
68
+ class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
69
+ @factory.post_generation
70
+ def create_instrument(self, create, extracted, **kwargs):
71
+ if create:
72
+ instrument = ProductFactory.create()
73
+ InstrumentPortfolioThroughModel.objects.update_or_create(
74
+ instrument=instrument, defaults={"portfolio": self}
75
+ )
@@ -1,11 +1,15 @@
1
1
  import random
2
2
 
3
3
  import factory
4
+ from faker import Faker
5
+ from pandas._libs.tslibs.offsets import BDay
4
6
 
5
7
  from wbportfolio.models import Trade, TradeProposal
6
8
 
7
9
  from .transactions import TransactionFactory
8
10
 
11
+ fake = Faker()
12
+
9
13
 
10
14
  class TradeFactory(TransactionFactory):
11
15
  class Meta:
@@ -22,7 +26,7 @@ class TradeProposalFactory(factory.django.DjangoModelFactory):
22
26
  class Meta:
23
27
  model = TradeProposal
24
28
 
25
- trade_date = factory.Faker("date_object")
29
+ trade_date = factory.LazyAttribute(lambda o: (fake.date_object() + BDay(1)).date())
26
30
  comment = factory.Faker("paragraph")
27
31
  portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
28
32
  creator = factory.SubFactory("wbcore.contrib.directory.factories.PersonFactory")
@@ -24,6 +24,7 @@ class TradeImportHandler(ImportExportHandler):
24
24
  self.instrument_handler = InstrumentImportHandler(self.import_source)
25
25
  self.register_handler = RegisterImportHandler(self.import_source)
26
26
  self.currency_handler = CurrencyImportHandler(self.import_source)
27
+ self.trade_proposals = set()
27
28
 
28
29
  def _data_changed(self, _object, change_data: Dict[str, Any], initial_data: Dict[str, Any], **kwargs):
29
30
  if (new_register := change_data.get("register")) and (current_register := _object.register):
@@ -42,6 +43,7 @@ class TradeImportHandler(ImportExportHandler):
42
43
 
43
44
  if trade_proposal_id := data.pop("trade_proposal_id", None):
44
45
  trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
46
+ self.trade_proposals.add(trade_proposal)
45
47
  data["value_date"] = trade_proposal.last_effective_date
46
48
  data["transaction_date"] = trade_proposal.trade_date
47
49
  data["trade_proposal"] = trade_proposal
@@ -180,6 +182,12 @@ class TradeImportHandler(ImportExportHandler):
180
182
  if instrument.instrument_type.key == "product":
181
183
  update_outstanding_shares_as_task.delay(instrument.id)
182
184
 
185
+ # if the trade import relates to a trade proposal, we reset the TP after the import to ensure it contains the deleted positions (often forgotten by user)
186
+ for changed_trade_proposal in self.trade_proposals:
187
+ changed_trade_proposal.reset_trades(
188
+ target_portfolio=changed_trade_proposal._build_dto().convert_to_portfolio()
189
+ )
190
+
183
191
  def _post_processing_updated_object(self, _object):
184
192
  if _object.marked_for_deletion:
185
193
  _object.marked_for_deletion = False
@@ -0,0 +1,28 @@
1
+ # Generated by Django 5.0.13 on 2025-03-20 08:19
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('wbportfolio', '0074_alter_rebalancer_frequency_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='portfolio',
15
+ name='initial_position_date',
16
+ field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='portfolio',
20
+ name='last_position_date',
21
+ field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
22
+ ),
23
+ migrations.AlterField(
24
+ model_name='trade',
25
+ name='transaction_subtype',
26
+ field=models.CharField(choices=[('REBALANCE', 'Rebalance'), ('DECREASE', 'Decrease'), ('INCREASE', 'Increase'), ('SUBSCRIPTION', 'Subscription'), ('REDEMPTION', 'Redemption'), ('BUY', 'Buy'), ('SELL', 'Sell'), ('NO_CHANGE', 'No Change')], default='BUY', max_length=32, verbose_name='Trade Type'),
27
+ ),
28
+ ]
@@ -29,7 +29,6 @@ from django.dispatch import receiver
29
29
  from django.utils import timezone
30
30
  from django.utils.functional import cached_property
31
31
  from pandas._libs.tslibs.offsets import BDay
32
- from psycopg.types.range import DateRange
33
32
  from skfolio.preprocessing import prices_to_returns
34
33
  from wbcore.contrib.currency.models import Currency, CurrencyFXRates
35
34
  from wbcore.contrib.notifications.utils import create_notification_type
@@ -293,6 +292,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
293
292
  default=False, help_text="If true, this portfolio is a composition of other portfolio"
294
293
  )
295
294
  updated_at = models.DateTimeField(blank=True, null=True, verbose_name="Updated At")
295
+ last_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
296
+ initial_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
297
+
296
298
  bank_accounts = models.ManyToManyField(
297
299
  to="directory.BankingContact",
298
300
  related_name="wbportfolio_portfolios",
@@ -699,6 +701,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
699
701
  self.evaluate_rebalancing(val_date)
700
702
 
701
703
  self.updated_at = timezone.now()
704
+ if self.assets.filter(date=val_date).exists():
705
+ if not self.last_position_date or self.last_position_date < val_date:
706
+ self.last_position_date = val_date
707
+ if not self.initial_position_date or self.initial_position_date > val_date:
708
+ self.initial_position_date = val_date
702
709
  self.save()
703
710
 
704
711
  if compute_metrics:
@@ -1259,18 +1266,6 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
1259
1266
  return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1260
1267
 
1261
1268
 
1262
- @receiver(post_save, sender="wbportfolio.Product")
1263
- @receiver(post_save, sender="wbportfolio.ProductGroup")
1264
- def post_product_creation(sender, instance, created, raw, **kwargs):
1265
- if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
1266
- portfolio = Portfolio.objects.create(
1267
- name=f"Portfolio: {instance.name}",
1268
- currency=instance.currency,
1269
- invested_timespan=DateRange(instance.inception_date if instance.inception_date else date.min, date.max),
1270
- )
1271
- InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
1272
-
1273
-
1274
1269
  @receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
1275
1270
  def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
1276
1271
  if (
@@ -133,7 +133,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
133
133
  trade_proposal.reset_trades(target_portfolio)
134
134
  trade_proposal.submit()
135
135
  if self.approve_trade_proposal_automatically and self.portfolio.can_be_rebalanced:
136
- trade_proposal.approve()
136
+ trade_proposal.approve(replay=False)
137
137
  except ValidationError:
138
138
  # If we encountered a validation error, we set the trade proposal as failed
139
139
  trade_proposal.status = TradeProposal.Status.FAILED
@@ -146,6 +146,12 @@ class Rebalancer(ComplexToStringMixin, models.Model):
146
146
  def rrule(self):
147
147
  return self.get_rrule()
148
148
 
149
+ def get_next_rebalancing_date(self, pivot_date: date) -> date | None:
150
+ for _dt in self.rrule:
151
+ _d = _dt.date()
152
+ if _d > pivot_date:
153
+ return _d
154
+
149
155
  @property
150
156
  def frequency_repr(self):
151
157
  return humanize_rrule(self.rrule)
@@ -11,12 +11,13 @@ from django.utils.functional import cached_property
11
11
  from django_fsm import FSMField, transition
12
12
  from pandas._libs.tslibs.offsets import BDay
13
13
  from wbcompliance.models.risk_management.mixins import RiskCheckMixin
14
+ from wbcore.contrib.currency.models import Currency
14
15
  from wbcore.contrib.icons import WBIcon
15
16
  from wbcore.enums import RequestType
16
17
  from wbcore.metadata.configs.buttons import ActionButton
17
18
  from wbcore.models import WBModel
18
19
  from wbcore.utils.models import CloneMixin
19
- from wbfdm.models.instruments.instruments import Instrument
20
+ from wbfdm.models.instruments.instruments import Cash, Instrument
20
21
 
21
22
  from wbportfolio.models.roles import PortfolioRole
22
23
  from wbportfolio.pms.trading import TradingService
@@ -89,6 +90,10 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
89
90
  def check_evaluation_date(self):
90
91
  return self.trade_date
91
92
 
93
+ @cached_property
94
+ def portfolio_total_asset_value(self) -> Decimal:
95
+ return self.portfolio.get_total_asset_value(self.last_effective_date)
96
+
92
97
  @cached_property
93
98
  def validated_trading_service(self) -> TradingService:
94
99
  """
@@ -96,8 +101,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
96
101
  """
97
102
  return TradingService(
98
103
  self.trade_date,
99
- effective_portfolio=self.portfolio._build_dto(self.trade_date),
100
- trades_batch=self._build_dto(),
104
+ effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
105
+ target_portfolio=self._build_dto().convert_to_portfolio(),
101
106
  )
102
107
 
103
108
  @cached_property
@@ -150,9 +155,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
150
155
  Returns:
151
156
  DTO trade object
152
157
  """
153
- return (
154
- TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()])) if self.trades.exists() else None
155
- )
158
+ return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
156
159
 
157
160
  # Start tools methods
158
161
  def _clone(self, **kwargs) -> SelfTradeProposal:
@@ -197,7 +200,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
197
200
  with suppress(Trade.DoesNotExist):
198
201
  trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
199
202
  trade.weighting = round(trade_dto.delta_weight, 6)
200
- trade.shares = self.estimate_shares(trade)
201
203
  trade.save()
202
204
  total_target_weight += trade._target_weight
203
205
  leftovers_trades = leftovers_trades.exclude(id=trade.id)
@@ -208,7 +210,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
208
210
  biggest_trade.weighting -= quantize_error
209
211
  biggest_trade.save()
210
212
 
211
- def _get_target_portfolio(self, **kwargs) -> PortfolioDTO:
213
+ def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
212
214
  if self.rebalancing_model:
213
215
  params = {}
214
216
  if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
@@ -217,50 +219,57 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
217
219
  return self.rebalancing_model.get_target_portfolio(
218
220
  self.portfolio, self.trade_date, self.last_effective_date, **params
219
221
  )
222
+ if self.trades.exists():
223
+ return self._build_dto().convert_to_portfolio()
220
224
  # Return the current portfolio by default
221
225
  return self.portfolio._build_dto(self.last_effective_date)
222
226
 
223
- def reset_trades(self, target_portfolio: PortfolioDTO | None = None):
227
+ def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
224
228
  """
225
229
  Will delete all existing trades and recreate them from the method `create_or_update_trades`
226
230
  """
227
231
  if self.status != TradeProposal.Status.DRAFT:
228
232
  raise ValueError("Cannot reset non-draft trade proposal. Revert this trade proposal first.")
229
233
  # delete all existing trades
230
- self.trades.all().delete()
231
234
  last_effective_date = self.last_effective_date
232
235
  # Get effective and target portfolio
233
236
  effective_portfolio = self.portfolio._build_dto(last_effective_date)
234
237
  if not target_portfolio:
235
- target_portfolio = self._get_target_portfolio()
236
- # if not effective_portfolio:
237
- # effective_portfolio = target_portfolio
238
- service = TradingService(
239
- self.trade_date,
240
- effective_portfolio=effective_portfolio,
241
- target_portfolio=target_portfolio,
242
- )
243
- service.normalize()
244
- service.is_valid()
245
- for trade_dto in service.validated_trades:
246
- instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
247
- currency_fx_rate = instrument.currency.convert(
248
- last_effective_date, self.portfolio.currency, exact_lookup=True
249
- )
250
- trade = Trade(
251
- underlying_instrument=instrument,
252
- transaction_subtype=Trade.Type.BUY if trade_dto.delta_weight > 0 else Trade.Type.SELL,
253
- currency=instrument.currency,
254
- value_date=last_effective_date,
255
- transaction_date=self.trade_date,
256
- trade_proposal=self,
257
- portfolio=self.portfolio,
258
- weighting=trade_dto.delta_weight,
259
- status=Trade.Status.DRAFT,
260
- currency_fx_rate=currency_fx_rate,
238
+ target_portfolio = self._get_default_target_portfolio()
239
+
240
+ if target_portfolio:
241
+ service = TradingService(
242
+ self.trade_date,
243
+ effective_portfolio=effective_portfolio,
244
+ target_portfolio=target_portfolio,
261
245
  )
262
- trade.shares = self.estimate_shares(trade)
263
- trade.save()
246
+ if validate_trade:
247
+ service.normalize()
248
+ service.is_valid()
249
+ trades = service.validated_trades
250
+ else:
251
+ trades = service.trades_batch.trades_map.values()
252
+ for trade_dto in trades:
253
+ instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
254
+ currency_fx_rate = instrument.currency.convert(
255
+ last_effective_date, self.portfolio.currency, exact_lookup=True
256
+ )
257
+ # we cannot do a bulk-create because Trade is a multi table inheritance
258
+ try:
259
+ trade = self.trades.get(underlying_instrument=instrument)
260
+ except Trade.DoesNotExist:
261
+ trade = Trade(
262
+ underlying_instrument=instrument,
263
+ currency=instrument.currency,
264
+ value_date=last_effective_date,
265
+ transaction_date=self.trade_date,
266
+ trade_proposal=self,
267
+ portfolio=self.portfolio,
268
+ weighting=trade_dto.delta_weight,
269
+ status=Trade.Status.DRAFT,
270
+ currency_fx_rate=currency_fx_rate,
271
+ )
272
+ trade.save()
264
273
 
265
274
  def replay(self):
266
275
  last_trade_proposal = self
@@ -275,12 +284,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
275
284
  if last_trade_proposal.status == TradeProposal.Status.DRAFT:
276
285
  if self.rebalancing_model: # 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
277
286
  logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
278
- self.reset_trades()
287
+ with suppress(
288
+ ValidationError
289
+ ): # 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
290
+ self.reset_trades()
279
291
  logger.info("Submitting trade proposal ...")
280
292
  last_trade_proposal.submit()
281
293
  if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
282
294
  logger.info("Approving trade proposal ...")
283
- last_trade_proposal.approve()
295
+ last_trade_proposal.approve(replay=False)
284
296
  last_trade_proposal.save()
285
297
  next_trade_proposal = last_trade_proposal.next_trade_proposal
286
298
  next_trade_date = (
@@ -291,14 +303,79 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
291
303
  )
292
304
  last_trade_proposal = overriding_trade_proposal or next_trade_proposal
293
305
 
294
- def estimate_shares(self, trade: Trade) -> Decimal | None:
295
- if not self.portfolio.only_weighting and (quote := trade.underlying_quote_price):
296
- trade_total_value_fx_portfolio = (
297
- self.portfolio.get_total_asset_value(trade.value_date) * trade._target_weight
306
+ def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal | None:
307
+ """
308
+ Estimates the number of shares for a trade based on the given weight and underlying quote.
309
+
310
+ 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.
311
+
312
+ Args:
313
+ weight (Decimal): The weight of the trade.
314
+ underlying_quote (Instrument): The underlying instrument for the trade.
315
+
316
+ Returns:
317
+ Decimal | None: The estimated number of shares or None if the calculation fails.
318
+ """
319
+ try:
320
+ # 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
321
+ quote_price = Decimal(underlying_quote.get_price(self.trade_date))
322
+
323
+ # Calculate the trade's total value in the portfolio's currency
324
+ trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
325
+
326
+ # Convert the quote price to the portfolio's currency
327
+ price_fx_portfolio = quote_price * underlying_quote.currency.convert(
328
+ self.trade_date, self.portfolio.currency, exact_lookup=False
298
329
  )
299
- price_fx_portfolio = quote.net_value * trade.currency_fx_rate
330
+
331
+ # If the price is valid, calculate and return the estimated shares
300
332
  if price_fx_portfolio:
301
333
  return trade_total_value_fx_portfolio / price_fx_portfolio
334
+ except Exception:
335
+ # Suppress any ValueError and return None if the calculation fails
336
+ return None
337
+
338
+ def get_estimated_target_cash(self, currency: Currency) -> tuple[Decimal, Decimal]:
339
+ """
340
+ Estimates the target cash weight and shares for a trade proposal.
341
+
342
+ 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.
343
+
344
+ Args:
345
+ currency (Currency): The currency for the target currency component
346
+
347
+ Returns:
348
+ tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
349
+ """
350
+ # Retrieve trades with base information
351
+ trades = self.trades.all().annotate_base_info()
352
+
353
+ # Calculate the target cash weight from cash trades
354
+ target_cash_weight = trades.filter(
355
+ underlying_instrument__is_cash=True, underlying_instrument__currency=currency
356
+ ).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
357
+ # if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
358
+ if currency == self.portfolio.currency:
359
+ # Calculate the total target weight of all trades
360
+ total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
361
+
362
+ # Add any leftover weight as cash
363
+ target_cash_weight += Decimal(1) - total_target_weight
364
+
365
+ # Initialize target shares to zero
366
+ total_target_shares = Decimal(0)
367
+
368
+ # If the portfolio is not only weighting-based, estimate the target shares for the cash component
369
+ if not self.portfolio.only_weighting:
370
+ # Get or create a cash component for the portfolio's currency
371
+ cash_component = Cash.objects.get_or_create(
372
+ currency=currency, defaults={"is_cash": True, "name": currency.title}
373
+ )[0]
374
+
375
+ # Estimate the target shares for the cash component
376
+ total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
377
+
378
+ return target_cash_weight, total_target_shares
302
379
 
303
380
  # Start FSM logics
304
381
 
@@ -323,9 +400,26 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
323
400
  )
324
401
  def submit(self, by=None, description=None, **kwargs):
325
402
  self.trades.update(comment="", status=Trade.Status.DRAFT)
403
+ self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
326
404
  for trade in self.trades.all():
327
405
  trade.submit()
328
406
  trade.save()
407
+
408
+ # If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
409
+ cash_target_cash_weight, cash_target_cash_shares = self.get_estimated_target_cash(self.portfolio.currency)
410
+ if cash_target_cash_weight:
411
+ cash_component = Cash.objects.get_or_create(
412
+ currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
413
+ )[0]
414
+ self.trades.update_or_create(
415
+ underlying_instrument=cash_component,
416
+ defaults={
417
+ "status": Trade.Status.SUBMIT,
418
+ "weighting": cash_target_cash_weight,
419
+ "shares": cash_target_cash_shares,
420
+ },
421
+ )
422
+
329
423
  self.evaluate_active_rules(
330
424
  self.trade_date, self.validated_trading_service.target_portfolio, asynchronously=True
331
425
  )
@@ -356,7 +450,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
356
450
 
357
451
  @property
358
452
  def can_be_approved_or_denied(self):
359
- return self.has_no_rule_or_all_checked_succeed and self.portfolio.is_manageable
453
+ return not self.has_non_successful_checks and self.portfolio.is_manageable
360
454
 
361
455
  @transition(
362
456
  field=status,
@@ -378,7 +472,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
378
472
  )
379
473
  },
380
474
  )
381
- def approve(self, by=None, description=None, synchronous=False, **kwargs):
475
+ def approve(self, by=None, description=None, replay: bool = True, **kwargs):
382
476
  # We validate trade which will create or update the initial asset positions
383
477
  if not self.portfolio.can_be_rebalanced:
384
478
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
@@ -387,6 +481,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
387
481
  for trade in self.trades.all():
388
482
  trade.execute()
389
483
  trade.save()
484
+ if replay and self.portfolio.is_manageable:
485
+ replay_as_task.delay(self.id)
390
486
 
391
487
  def can_approve(self):
392
488
  errors = dict()
@@ -398,7 +494,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
398
494
  errors["portfolio"] = (
399
495
  "The portfolio needs to be a model portfolio in order to approve this trade proposal manually"
400
496
  )
401
- if self.has_assigned_active_rules and not self.has_all_check_completed_and_succeed:
497
+ if self.has_non_successful_checks:
402
498
  errors["non_field_errors"] = "The pre trades rules did not passed successfully"
403
499
  return errors
404
500
 
@@ -440,7 +536,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
440
536
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
441
537
  user.profile, portfolio=instance.portfolio
442
538
  )
443
- and instance.has_all_check_completed, # we wait for all checks to succeed before proposing the back to draft transition
539
+ and instance.has_all_check_completed
540
+ or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
444
541
  custom={
445
542
  "_transition_button": ActionButton(
446
543
  method=RequestType.PATCH,