wbportfolio 1.52.5__py2.py3-none-any.whl → 1.53.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.

@@ -19,6 +19,7 @@ class PortfolioFactory(factory.django.DjangoModelFactory):
19
19
  is_manageable = True
20
20
  is_tracked = True
21
21
  is_lookthrough = False
22
+ only_weighting = True
22
23
  invested_timespan = DateRange(date.min, date.max)
23
24
 
24
25
  @factory.post_generation
@@ -0,0 +1,26 @@
1
+ # Generated by Django 5.0.14 on 2025-07-02 13:10
2
+
3
+ from decimal import Decimal
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('wbportfolio', '0077_remove_transaction_currency_and_more'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='trade',
16
+ name='drift_factor',
17
+ field=models.DecimalField(decimal_places=6, default=Decimal('1'), help_text='Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return', max_digits=16, verbose_name='Drift Factor'),
18
+ ),
19
+ migrations.AlterField(
20
+ model_name='trade',
21
+ name='weighting',
22
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'),
23
+ help_text='The weight to be multiplied against the target', max_digits=9,
24
+ verbose_name='Weight'),
25
+ ),
26
+ ]
@@ -693,7 +693,7 @@ class AssetPosition(ImportMixin, models.Model):
693
693
  def get_portfolio_total_asset_value(self) -> Decimal:
694
694
  return self.portfolio.get_total_asset_value(self.date)
695
695
 
696
- def _build_dto(self, new_weight: Decimal = None) -> PositionDTO:
696
+ def _build_dto(self, new_weight: Decimal = None, **kwargs) -> PositionDTO:
697
697
  """
698
698
  Data Transfer Object
699
699
  Returns:
@@ -708,7 +708,7 @@ class AssetPosition(ImportMixin, models.Model):
708
708
  instrument_type=self.underlying_quote.security_instrument_type.id,
709
709
  currency=self.underlying_quote.currency.id,
710
710
  country=self.underlying_quote.country.id if self.underlying_quote.country else None,
711
- is_cash=self.underlying_quote.is_cash,
711
+ is_cash=self.underlying_quote.is_cash or self.underlying_quote.is_cash_equivalent,
712
712
  primary_classification=(
713
713
  self.underlying_quote.primary_classification.id
714
714
  if self.underlying_quote.primary_classification
@@ -726,6 +726,7 @@ class AssetPosition(ImportMixin, models.Model):
726
726
  price=self._price,
727
727
  currency_fx_rate=self._currency_fx_rate,
728
728
  portfolio_created=self.portfolio_created.id if self.portfolio_created else None,
729
+ **kwargs,
729
730
  )
730
731
 
731
732
  @cached_property
@@ -355,8 +355,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
355
355
 
356
356
  def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
357
357
  "returns the dto representation of this portfolio at the specified date"
358
+ assets = self.assets.filter(date=val_date, **extra_kwargs)
359
+ try:
360
+ drifted_weights = self.get_analytic_portfolio(val_date).get_next_weights()
361
+ except InvalidAnalyticPortfolio:
362
+ drifted_weights = {}
358
363
  return PortfolioDTO(
359
- tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_kwargs)]),
364
+ tuple(
365
+ [
366
+ pos._build_dto(
367
+ drift_factor=drifted_weights.get(pos.underlying_quote.id, float(pos.weighting))
368
+ / float(pos.weighting)
369
+ if pos.weighting
370
+ else Decimal(1.0)
371
+ )
372
+ for pos in assets
373
+ ]
374
+ ),
360
375
  )
361
376
 
362
377
  def get_weights(self, val_date: date) -> dict[int, float]:
@@ -926,7 +941,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
926
941
  if self.is_composition:
927
942
  assets = list(self.get_lookthrough_positions(val_date, **kwargs))
928
943
  else:
929
- assets = self.assets.filter(date=val_date)
944
+ assets = list(self.assets.filter(date=val_date))
930
945
  return assets
931
946
 
932
947
  def compute_lookthrough(self, from_date: date, to_date: date | None = None):
@@ -1,11 +1,11 @@
1
1
  import logging
2
+ import math
2
3
  from contextlib import suppress
3
4
  from datetime import date, timedelta
4
5
  from decimal import Decimal
5
- from typing import TypeVar
6
+ from typing import Any, TypeVar
6
7
 
7
8
  from celery import shared_task
8
- from django.contrib.messages import error, info
9
9
  from django.core.exceptions import ValidationError
10
10
  from django.db import models
11
11
  from django.utils.functional import cached_property
@@ -92,14 +92,14 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
92
92
  )
93
93
  super().save(*args, **kwargs)
94
94
 
95
- @property
96
- def checked_object(self):
97
- return self.portfolio
98
-
99
95
  @property
100
96
  def check_evaluation_date(self):
101
97
  return self.trade_date
102
98
 
99
+ @property
100
+ def checked_object(self) -> Any:
101
+ return self.portfolio
102
+
103
103
  @cached_property
104
104
  def portfolio_total_asset_value(self) -> Decimal:
105
105
  return self.portfolio.get_total_asset_value(self.last_effective_date)
@@ -109,10 +109,13 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
109
109
  """
110
110
  This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
111
111
  """
112
+ target_portfolio = self._build_dto().convert_to_portfolio()
113
+
112
114
  return TradingService(
113
115
  self.trade_date,
114
- effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
115
- target_portfolio=self._build_dto().convert_to_portfolio(),
116
+ effective_portfolio=self._get_default_effective_portfolio(),
117
+ target_portfolio=target_portfolio,
118
+ total_target_weight=target_portfolio.total_weight,
116
119
  )
117
120
 
118
121
  @cached_property
@@ -197,25 +200,30 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
197
200
 
198
201
  return trade_proposal_clone
199
202
 
200
- def normalize_trades(self):
203
+ def normalize_trades(self, total_target_weight: Decimal = Decimal("1.0")):
201
204
  """
202
205
  Call the trading service with the existing trades and normalize them in order to obtain a total sum target weight of 100%
203
206
  The existing trade will be modified directly with the given normalization factor
204
207
  """
205
- service = TradingService(self.trade_date, trades_batch=self._build_dto())
206
- service.normalize()
208
+ service = TradingService(
209
+ self.trade_date,
210
+ effective_portfolio=self._get_default_effective_portfolio(),
211
+ target_portfolio=self._build_dto().convert_to_portfolio(),
212
+ total_target_weight=total_target_weight,
213
+ )
207
214
  leftovers_trades = self.trades.all()
208
- total_target_weight = Decimal("0.0")
209
215
  for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
210
216
  with suppress(Trade.DoesNotExist):
211
217
  trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
212
218
  trade.weighting = round(trade_dto.delta_weight, 6)
213
219
  trade.save()
214
- total_target_weight += trade._target_weight
215
220
  leftovers_trades = leftovers_trades.exclude(id=trade.id)
216
221
  leftovers_trades.delete()
222
+ t_weight = self.trades.all().annotate_base_info().aggregate(models.Sum("target_weight"))[
223
+ "target_weight__sum"
224
+ ] or Decimal("0.0")
217
225
  # 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
218
- if quantize_error := (total_target_weight - Decimal("1.0")):
226
+ if quantize_error := (t_weight - total_target_weight):
219
227
  biggest_trade = self.trades.latest("weighting")
220
228
  biggest_trade.weighting -= quantize_error
221
229
  biggest_trade.save()
@@ -232,23 +240,31 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
232
240
  if self.trades.exists():
233
241
  return self._build_dto().convert_to_portfolio()
234
242
  # Return the current portfolio by default
243
+ return self._get_default_effective_portfolio()
244
+
245
+ def _get_default_effective_portfolio(self):
235
246
  return self.portfolio._build_dto(self.last_effective_date)
236
247
 
237
- def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
248
+ def reset_trades(
249
+ self,
250
+ target_portfolio: PortfolioDTO | None = None,
251
+ validate_trade: bool = True,
252
+ total_target_weight: Decimal = Decimal("1.0"),
253
+ ):
238
254
  """
239
255
  Will delete all existing trades and recreate them from the method `create_or_update_trades`
240
256
  """
241
257
  # delete all existing trades
242
258
  last_effective_date = self.last_effective_date
243
259
  # Get effective and target portfolio
244
- effective_portfolio = self.portfolio._build_dto(last_effective_date)
245
260
  if not target_portfolio:
246
261
  target_portfolio = self._get_default_target_portfolio()
247
262
  if target_portfolio:
248
263
  service = TradingService(
249
264
  self.trade_date,
250
- effective_portfolio=effective_portfolio,
265
+ effective_portfolio=self._get_default_effective_portfolio(),
251
266
  target_portfolio=target_portfolio,
267
+ total_target_weight=total_target_weight,
252
268
  )
253
269
  if validate_trade:
254
270
  service.is_valid()
@@ -262,11 +278,13 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
262
278
  )
263
279
  # we cannot do a bulk-create because Trade is a multi table inheritance
264
280
  weighting = round(trade_dto.delta_weight, 6)
281
+ drift_factor = trade_dto.drift_factor
265
282
  try:
266
283
  trade = self.trades.get(underlying_instrument=instrument)
267
284
  trade.weighting = weighting
268
285
  trade.currency_fx_rate = currency_fx_rate
269
286
  trade.status = Trade.Status.DRAFT
287
+ trade.drift_factor = drift_factor
270
288
  except Trade.DoesNotExist:
271
289
  trade = Trade(
272
290
  underlying_instrument=instrument,
@@ -276,9 +294,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
276
294
  trade_proposal=self,
277
295
  portfolio=self.portfolio,
278
296
  weighting=weighting,
297
+ drift_factor=drift_factor,
279
298
  status=Trade.Status.DRAFT,
280
299
  currency_fx_rate=currency_fx_rate,
281
300
  )
301
+ trade.price = trade.get_price()
302
+ # if we cannot automatically find a price, we consider the stock is invalid and we sell it
303
+ if trade.price is None:
304
+ trade.price = Decimal("0.0")
305
+ trade.weighting = -trade_dto.effective_weight
282
306
  trade.save()
283
307
 
284
308
  def replay(self):
@@ -329,7 +353,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
329
353
  last_trade_proposal_created = False
330
354
  last_trade_proposal = next_trade_proposal
331
355
 
332
- def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal:
356
+ def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal) -> Decimal:
333
357
  """
334
358
  Estimates the number of shares for a trade based on the given weight and underlying quote.
335
359
 
@@ -342,23 +366,29 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
342
366
  Returns:
343
367
  Decimal | None: The estimated number of shares or None if the calculation fails.
344
368
  """
345
- try:
346
- # 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
347
- quote_price = Decimal(underlying_quote.get_price(self.trade_date))
369
+ # 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
348
370
 
349
- # Calculate the trade's total value in the portfolio's currency
350
- trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
371
+ # Calculate the trade's total value in the portfolio's currency
372
+ trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
351
373
 
352
- # Convert the quote price to the portfolio's currency
353
- price_fx_portfolio = quote_price * underlying_quote.currency.convert(
354
- self.trade_date, self.portfolio.currency, exact_lookup=False
355
- )
374
+ # Convert the quote price to the portfolio's currency
375
+ price_fx_portfolio = quote_price * underlying_quote.currency.convert(
376
+ self.trade_date, self.portfolio.currency, exact_lookup=False
377
+ )
378
+
379
+ # If the price is valid, calculate and return the estimated shares
380
+ if price_fx_portfolio:
381
+ return trade_total_value_fx_portfolio / price_fx_portfolio
356
382
 
357
- # If the price is valid, calculate and return the estimated shares
358
- if price_fx_portfolio:
359
- return trade_total_value_fx_portfolio / price_fx_portfolio
360
- except Exception:
361
- raise ValueError("We couldn't estimate the number of shares")
383
+ def get_round_lot_size(self, shares: Decimal, underlying_quote: Instrument) -> Decimal:
384
+ if (round_lot_size := underlying_quote.round_lot_size) != 1 and (
385
+ not underlying_quote.exchange or underlying_quote.exchange.apply_round_lot_size
386
+ ):
387
+ if shares > 0:
388
+ shares = math.ceil(shares / round_lot_size) * round_lot_size
389
+ elif abs(shares) > round_lot_size:
390
+ shares = math.floor(shares / round_lot_size) * round_lot_size
391
+ return shares
362
392
 
363
393
  def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
364
394
  """
@@ -399,7 +429,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
399
429
 
400
430
  # Estimate the target shares for the cash component
401
431
  with suppress(ValueError):
402
- total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
432
+ total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component, Decimal("1.0"))
403
433
 
404
434
  cash_component = Cash.objects.get_or_create(
405
435
  currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
@@ -447,46 +477,25 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
447
477
  },
448
478
  )
449
479
  def submit(self, by=None, description=None, **kwargs):
450
- self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
451
480
  trades = []
452
- trades_validation_errors = []
481
+ trades_validation_warnings = []
453
482
  for trade in self.trades.all():
454
- if not trade.last_underlying_quote_price:
455
- trade.status = Trade.Status.FAILED
456
- trades_validation_errors.append(
457
- f"Trade failed because no price is found for {trade.underlying_instrument.computed_str} on {trade.transaction_date:%Y-%m-%d}"
458
- )
459
- else:
460
- trade.status = Trade.Status.SUBMIT
483
+ trade_warnings = trade.submit(
484
+ by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
485
+ )
486
+ if trade_warnings:
487
+ trades_validation_warnings.extend(trade_warnings)
461
488
  trades.append(trade)
462
489
 
463
- Trade.objects.bulk_update(trades, ["status"])
490
+ Trade.objects.bulk_update(trades, ["status", "shares", "weighting"])
464
491
 
465
492
  # If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
466
493
  estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
467
- target_portfolio = self.validated_trading_service.target_portfolio
468
- if estimated_cash_position.weighting:
469
- if existing_cash_position := target_portfolio.positions_map.get(
470
- estimated_cash_position.underlying_quote.id
471
- ):
472
- existing_cash_position += estimated_cash_position
473
- else:
474
- target_portfolio.positions_map[estimated_cash_position.underlying_quote.id] = (
475
- estimated_cash_position._build_dto()
476
- )
477
- target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
494
+ target_portfolio = self.validated_trading_service.trades_batch.convert_to_portfolio(
495
+ estimated_cash_position._build_dto()
496
+ )
478
497
  self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
479
-
480
- if (
481
- trades_validation_errors
482
- and (fsm_context := getattr(self, "fsm_context", None))
483
- and (request := fsm_context.get("request", None))
484
- ):
485
- msg = f"""The reason for the failed trades are: <ul>
486
- {''.join(map(lambda o: '<li>' + o + '</li>', trades_validation_errors))}
487
- </ul>
488
- """
489
- error(request, msg, extra_tags="auto_close=0")
498
+ return trades_validation_warnings
490
499
 
491
500
  def can_submit(self):
492
501
  errors = dict()
@@ -496,7 +505,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
496
505
  service = self.validated_trading_service
497
506
  try:
498
507
  service.is_valid(ignore_error=True)
499
- # if service.trades_batch.totat_abs_delta_weight == 0:
508
+ # if service.trades_batch.total_abs_delta_weight == 0:
500
509
  # errors_list.append(
501
510
  # "There is no change detected in this trade proposal. Please submit at last one valid trade"
502
511
  # )
@@ -542,6 +551,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
542
551
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
543
552
  trades = []
544
553
  assets = []
554
+ warnings = []
545
555
  # We do not want to create the estimated cash position if there is not trades in the trade proposal (shouldn't be possible anyway)
546
556
  estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
547
557
 
@@ -556,13 +566,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
556
566
 
557
567
  # if there is cash leftover, we create an extra asset position to hold the cash component
558
568
  if estimated_cash_position.weighting and len(trades) > 0:
569
+ warnings.append(
570
+ f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
571
+ )
559
572
  estimated_cash_position.pre_save()
560
573
  assets.append(estimated_cash_position)
561
- if (fsm_context := getattr(self, "fsm_context", None)) and (request := fsm_context.get("request", None)):
562
- info(
563
- request,
564
- f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}",
565
- )
566
574
 
567
575
  Trade.objects.bulk_update(trades, ["status"])
568
576
  self.portfolio.bulk_create_positions(
@@ -570,6 +578,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
570
578
  )
571
579
  if replay and self.portfolio.is_manageable:
572
580
  replay_as_task.delay(self.id, user_id=by.id if by else None)
581
+ return warnings
573
582
 
574
583
  def can_approve(self):
575
584
  errors = dict()
@@ -53,7 +53,7 @@ class TradeQueryset(OrderedModelQuerySet):
53
53
  .order_by("-date")
54
54
  .values("date")[:1]
55
55
  ),
56
- effective_weight=Coalesce(
56
+ _previous_weight=Coalesce(
57
57
  Subquery(
58
58
  AssetPosition.unannotated_objects.filter(
59
59
  underlying_quote=OuterRef("underlying_instrument"),
@@ -66,6 +66,7 @@ class TradeQueryset(OrderedModelQuerySet):
66
66
  ),
67
67
  Decimal(0),
68
68
  ),
69
+ effective_weight=F("_previous_weight") * F("drift_factor"),
69
70
  target_weight=F("effective_weight") + F("weighting"),
70
71
  effective_shares=Coalesce(
71
72
  Subquery(
@@ -163,8 +164,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
163
164
  )
164
165
 
165
166
  weighting = models.DecimalField(
166
- max_digits=16,
167
- decimal_places=6,
167
+ max_digits=9,
168
+ decimal_places=8,
168
169
  default=Decimal(0),
169
170
  help_text="The weight to be multiplied against the target",
170
171
  verbose_name="Weight",
@@ -223,7 +224,13 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
223
224
  on_delete=models.CASCADE,
224
225
  help_text="The Trade Proposal this trade is coming from",
225
226
  )
226
-
227
+ drift_factor = models.DecimalField(
228
+ max_digits=16,
229
+ decimal_places=6,
230
+ default=Decimal(1.0),
231
+ verbose_name="Drift Factor",
232
+ help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
233
+ )
227
234
  external_id = models.CharField(
228
235
  max_length=255,
229
236
  null=True,
@@ -249,9 +256,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
249
256
  field=status,
250
257
  source=Status.DRAFT,
251
258
  target=GET_STATE(
252
- lambda self, **kwargs: (
253
- self.Status.SUBMIT if self.last_underlying_quote_price is not None else self.Status.FAILED
254
- ),
259
+ lambda self, **kwargs: (self.Status.SUBMIT if self.price else self.Status.FAILED),
255
260
  states=[Status.SUBMIT, Status.FAILED],
256
261
  ),
257
262
  permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
@@ -270,8 +275,27 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
270
275
  },
271
276
  on_error="FAILED",
272
277
  )
273
- def submit(self, by=None, description=None, **kwargs):
274
- pass
278
+ def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
279
+ warnings = []
280
+ # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
281
+ if self.trade_proposal and not self.portfolio.only_weighting:
282
+ shares = self.trade_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
283
+ if shares != self.shares:
284
+ warnings.append(
285
+ f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
286
+ )
287
+ shares = round(shares) # ensure fractional shares are converted into integer
288
+ # we need to recompute the delta weight has we changed the number of shares
289
+ if shares != self.shares:
290
+ self.shares = shares
291
+ if portfolio_total_asset_value:
292
+ self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
293
+
294
+ if not self.price:
295
+ warnings.append(
296
+ f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
297
+ )
298
+ return warnings
275
299
 
276
300
  def can_submit(self):
277
301
  pass
@@ -287,6 +311,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
287
311
  def fail(self, **kwargs):
288
312
  pass
289
313
 
314
+ # TODO To be removed
290
315
  @cached_property
291
316
  def last_underlying_quote_price(self) -> InstrumentPrice | None:
292
317
  try:
@@ -343,7 +368,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
343
368
  )
344
369
 
345
370
  def can_execute(self):
346
- if not self.last_underlying_quote_price:
371
+ if not self.price:
347
372
  return {"underlying_instrument": [_("Cannot execute a trade without a valid quote price")]}
348
373
  if not self.portfolio.is_manageable:
349
374
  return {
@@ -453,16 +478,14 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
453
478
  @property
454
479
  @admin.display(description="Effective Weight")
455
480
  def _effective_weight(self) -> Decimal:
456
- return getattr(
457
- self,
458
- "effective_weight",
459
- AssetPosition.unannotated_objects.filter(
460
- underlying_quote=self.underlying_instrument,
461
- date=self._last_effective_date,
462
- portfolio=self.portfolio,
463
- ).aggregate(s=Sum("weighting"))["s"]
464
- or Decimal(0),
465
- )
481
+ if hasattr(self, "effective_weight"):
482
+ return self.effective_weight
483
+ previous_weight = AssetPosition.unannotated_objects.filter(
484
+ underlying_quote=self.underlying_instrument,
485
+ date=self._last_effective_date,
486
+ portfolio=self.portfolio,
487
+ ).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
488
+ return previous_weight * self.drift_factor
466
489
 
467
490
  @property
468
491
  @admin.display(description="Effective Shares")
@@ -531,19 +554,24 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
531
554
 
532
555
  def save(self, *args, **kwargs):
533
556
  if self.trade_proposal:
557
+ if not self.underlying_instrument.is_investable_universe:
558
+ self.underlying_instrument.is_investable_universe = True
559
+ self.underlying_instrument.save()
534
560
  self.portfolio = self.trade_proposal.portfolio
535
561
  self.transaction_date = self.trade_proposal.trade_date
536
562
  self.value_date = self.trade_proposal.last_effective_date
537
- if not self.portfolio.only_weighting:
538
- with suppress(ValueError):
539
- self.shares = self.trade_proposal.get_estimated_shares(self.weighting, self.underlying_instrument)
563
+ if self.price is None:
564
+ # we try to get the price if not provided directly from the underlying instrument
565
+ self.price = self.get_price()
566
+ if self.trade_proposal and not self.portfolio.only_weighting:
567
+ estimated_shares = self.trade_proposal.get_estimated_shares(
568
+ self.weighting, self.underlying_instrument, self.price
569
+ )
570
+ if estimated_shares:
571
+ self.shares = estimated_shares
540
572
 
541
573
  if not self.custodian and self.bank:
542
574
  self.custodian = Custodian.get_by_mapping(self.bank)
543
- if self.price is None:
544
- # we try to get the price if not provided directly from the underlying instrument
545
- with suppress(Exception):
546
- self.price = self.underlying_instrument.get_price(self.value_date)
547
575
 
548
576
  if self.transaction_subtype is None or self.trade_proposal:
549
577
  # if subtype not provided, we extract it automatically from the existing data.
@@ -588,9 +616,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
588
616
  """
589
617
 
590
618
  def get_asset(self) -> AssetPosition:
591
- last_underlying_quote_price = self.last_underlying_quote_price
592
- if not last_underlying_quote_price:
593
- raise ValueError("No price found")
594
619
  asset = AssetPosition(
595
620
  underlying_quote=self.underlying_instrument,
596
621
  portfolio_created=None,
@@ -598,9 +623,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
598
623
  date=self.transaction_date,
599
624
  initial_currency_fx_rate=self.currency_fx_rate,
600
625
  weighting=self._target_weight,
601
- initial_price=self.last_underlying_quote_price.net_value,
626
+ initial_price=self.price,
602
627
  initial_shares=None,
603
- underlying_quote_price=self.last_underlying_quote_price,
604
628
  asset_valuation_date=self.transaction_date,
605
629
  currency=self.currency,
606
630
  is_estimated=False,
@@ -609,6 +633,11 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
609
633
  asset.pre_save()
610
634
  return asset
611
635
 
636
+ def get_price(self) -> Decimal | None:
637
+ if self.value_date:
638
+ with suppress(ValueError):
639
+ return Decimal(self.underlying_instrument.get_price(self.value_date))
640
+
612
641
  def delete(self, **kwargs):
613
642
  pre_collection.send(sender=self.__class__, instance=self)
614
643
  super().delete(**kwargs)
@@ -628,9 +657,14 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
628
657
  underlying_instrument=self.underlying_instrument.id,
629
658
  effective_weight=self._effective_weight,
630
659
  target_weight=self._target_weight,
660
+ effective_shares=self._effective_shares,
661
+ target_shares=self._target_shares,
662
+ currency_fx_rate=self.currency_fx_rate,
663
+ price=self.price,
631
664
  instrument_type=self.underlying_instrument.security_instrument_type.id,
632
665
  currency=self.underlying_instrument.currency,
633
666
  date=self.transaction_date,
667
+ is_cash=self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent,
634
668
  )
635
669
 
636
670
  def get_alternative_valid_trades(self, share_delta: float = 0):