wbportfolio 1.54.3__py2.py3-none-any.whl → 1.54.4__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.

@@ -7,7 +7,9 @@ from typing import Any, TypeVar
7
7
 
8
8
  from celery import shared_task
9
9
  from django.core.exceptions import ValidationError
10
- from django.db import models
10
+ from django.db import DatabaseError, models
11
+ from django.db.models.signals import post_save
12
+ from django.dispatch import receiver
11
13
  from django.utils.functional import cached_property
12
14
  from django.utils.translation import gettext_lazy as _
13
15
  from django_fsm import FSMField, transition
@@ -27,9 +29,10 @@ from wbfdm.models.instruments.instruments import Cash, Instrument
27
29
  from wbportfolio.models.roles import PortfolioRole
28
30
  from wbportfolio.pms.trading import TradingService
29
31
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
30
- from wbportfolio.pms.typing import TradeBatch as TradeBatchDTO
32
+ from wbportfolio.pms.typing import Position as PositionDTO
31
33
 
32
34
  from ..asset import AssetPosition, AssetPositionIterator
35
+ from ..exceptions import InvalidAnalyticPortfolio
33
36
  from .trades import Trade
34
37
 
35
38
  logger = logging.getLogger("pms")
@@ -109,7 +112,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
109
112
  """
110
113
  This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
111
114
  """
112
- target_portfolio = self._build_dto().convert_to_portfolio()
115
+ target_portfolio = self.convert_to_portfolio()
113
116
 
114
117
  return TradingService(
115
118
  self.trade_date,
@@ -123,7 +126,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
123
126
  try:
124
127
  return self.portfolio.assets.filter(date__lt=self.trade_date).latest("date").date
125
128
  except AssetPosition.DoesNotExist:
126
- return (self.trade_date - BDay(1)).date()
129
+ return self.value_date
130
+
131
+ @cached_property
132
+ def value_date(self) -> date:
133
+ return (self.trade_date - BDay(1)).date()
127
134
 
128
135
  @property
129
136
  def previous_trade_proposal(self) -> SelfTradeProposal | None:
@@ -162,13 +169,61 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
162
169
  def __str__(self) -> str:
163
170
  return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
164
171
 
165
- def _build_dto(self) -> TradeBatchDTO:
172
+ def convert_to_portfolio(self, use_effective: bool = False) -> PortfolioDTO:
166
173
  """
167
174
  Data Transfer Object
168
175
  Returns:
169
176
  DTO trade object
170
177
  """
171
- return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
178
+ portfolio = {}
179
+ for asset in self.portfolio.assets.filter(date=self.last_effective_date):
180
+ portfolio[asset.underlying_quote] = dict(
181
+ shares=asset._shares,
182
+ weighting=asset.weighting,
183
+ delta_weight=Decimal("0"),
184
+ price=asset._price,
185
+ currency_fx_rate=asset._currency_fx_rate,
186
+ )
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,
194
+ )
195
+
196
+ previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
197
+ try:
198
+ drifted_weights = self.portfolio.get_analytic_portfolio(
199
+ self.value_date, weights=previous_weights
200
+ ).get_next_weights()
201
+ except InvalidAnalyticPortfolio:
202
+ drifted_weights = {}
203
+ positions = []
204
+ for instrument, row in portfolio.items():
205
+ 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")
210
+ if not use_effective:
211
+ weighting = weighting * drift_factor + row["delta_weight"]
212
+ positions.append(
213
+ PositionDTO(
214
+ underlying_instrument=instrument.id,
215
+ instrument_type=instrument.instrument_type.id,
216
+ weighting=weighting,
217
+ drift_factor=drift_factor if use_effective else Decimal("1"),
218
+ shares=row["shares"],
219
+ currency=instrument.currency.id,
220
+ date=self.last_effective_date if use_effective else self.trade_date,
221
+ is_cash=instrument.is_cash or instrument.is_cash_equivalent,
222
+ price=row["price"],
223
+ currency_fx_rate=row["currency_fx_rate"],
224
+ )
225
+ )
226
+ return PortfolioDTO(positions)
172
227
 
173
228
  # Start tools methods
174
229
  def _clone(self, **kwargs) -> SelfTradeProposal:
@@ -208,14 +263,14 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
208
263
  service = TradingService(
209
264
  self.trade_date,
210
265
  effective_portfolio=self._get_default_effective_portfolio(),
211
- target_portfolio=self._build_dto().convert_to_portfolio(),
266
+ target_portfolio=self.convert_to_portfolio(),
212
267
  total_target_weight=total_target_weight,
213
268
  )
214
269
  leftovers_trades = self.trades.all()
215
270
  for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
216
271
  with suppress(Trade.DoesNotExist):
217
272
  trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
218
- trade.weighting = round(trade_dto.delta_weight, 6)
273
+ trade.weighting = round(trade_dto.delta_weight, Trade.TRADE_WEIGHTING_PRECISION)
219
274
  trade.save()
220
275
  leftovers_trades = leftovers_trades.exclude(id=trade.id)
221
276
  leftovers_trades.delete()
@@ -235,34 +290,39 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
235
290
  params.update(rebalancer.parameters)
236
291
  params.update(kwargs)
237
292
  return self.rebalancing_model.get_target_portfolio(
238
- self.portfolio, self.trade_date, self.last_effective_date, **params
293
+ self.portfolio, self.trade_date, self.value_date, **params
239
294
  )
240
295
  if self.trades.exists():
241
- return self._build_dto().convert_to_portfolio()
296
+ return self.convert_to_portfolio()
242
297
  # Return the current portfolio by default
243
- return self._get_default_effective_portfolio()
298
+ return self.convert_to_portfolio(use_effective=False)
244
299
 
245
300
  def _get_default_effective_portfolio(self):
246
- return self.portfolio._build_dto(self.last_effective_date)
301
+ return self.convert_to_portfolio(use_effective=True)
247
302
 
248
303
  def reset_trades(
249
304
  self,
250
305
  target_portfolio: PortfolioDTO | None = None,
306
+ effective_portfolio: PortfolioRole | None = None,
251
307
  validate_trade: bool = True,
252
308
  total_target_weight: Decimal = Decimal("1.0"),
253
309
  ):
254
310
  """
255
311
  Will delete all existing trades and recreate them from the method `create_or_update_trades`
256
312
  """
313
+ if self.rebalancing_model:
314
+ self.trades.all().delete()
257
315
  # delete all existing trades
258
- last_effective_date = self.last_effective_date
259
316
  # Get effective and target portfolio
260
317
  if not target_portfolio:
261
318
  target_portfolio = self._get_default_target_portfolio()
319
+ if not effective_portfolio:
320
+ effective_portfolio = self._get_default_effective_portfolio()
321
+
262
322
  if target_portfolio:
263
323
  service = TradingService(
264
324
  self.trade_date,
265
- effective_portfolio=self._get_default_effective_portfolio(),
325
+ effective_portfolio=effective_portfolio,
266
326
  target_portfolio=target_portfolio,
267
327
  total_target_weight=total_target_weight,
268
328
  )
@@ -273,78 +333,109 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
273
333
  trades = service.trades_batch.trades_map.values()
274
334
  for trade_dto in trades:
275
335
  instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
276
- currency_fx_rate = instrument.currency.convert(
277
- last_effective_date, self.portfolio.currency, exact_lookup=True
278
- )
279
- # we cannot do a bulk-create because Trade is a multi table inheritance
280
- weighting = round(trade_dto.delta_weight, 6)
281
- drift_factor = trade_dto.drift_factor
282
- try:
283
- trade = self.trades.get(underlying_instrument=instrument)
284
- trade.weighting = weighting
285
- trade.currency_fx_rate = currency_fx_rate
286
- trade.status = Trade.Status.DRAFT
287
- trade.drift_factor = drift_factor
288
- except Trade.DoesNotExist:
289
- trade = Trade(
290
- underlying_instrument=instrument,
291
- currency=instrument.currency,
292
- value_date=last_effective_date,
293
- transaction_date=self.trade_date,
294
- trade_proposal=self,
295
- portfolio=self.portfolio,
296
- weighting=weighting,
297
- drift_factor=drift_factor,
298
- status=Trade.Status.DRAFT,
299
- currency_fx_rate=currency_fx_rate,
336
+ if not instrument.is_cash: # we do not save trade that includes cash component
337
+ currency_fx_rate = instrument.currency.convert(
338
+ self.value_date, self.portfolio.currency, exact_lookup=True
300
339
  )
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
306
- trade.save()
307
-
308
- def replay(self):
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
343
+ 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(
351
+ 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,
357
+ weighting=weighting,
358
+ drift_factor=drift_factor,
359
+ status=Trade.Status.DRAFT,
360
+ currency_fx_rate=currency_fx_rate,
361
+ )
362
+ trade.price = trade.get_price()
363
+ # if we cannot automatically find a price, we consider the stock is invalid and we sell it
364
+ if trade.price is None:
365
+ trade.price = Decimal("0.0")
366
+ trade.weighting = -trade_dto.effective_weight
367
+
368
+ trade.save()
369
+
370
+ def approve_workflow(
371
+ self,
372
+ approve_automatically: bool = True,
373
+ silent_exception: bool = False,
374
+ force_reset_trade: bool = False,
375
+ **reset_trades_kwargs,
376
+ ):
377
+ if self.status == TradeProposal.Status.APPROVED:
378
+ logger.info("Reverting trade proposal ...")
379
+ self.revert()
380
+ if self.status == TradeProposal.Status.DRAFT:
381
+ if (
382
+ self.rebalancing_model or force_reset_trade
383
+ ): # 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
384
+ logger.info("Resetting trades ...")
385
+ 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
386
+ self.reset_trades(**reset_trades_kwargs)
387
+ except (ValidationError, DatabaseError) as e:
388
+ self.status = TradeProposal.Status.FAILED
389
+ if not silent_exception:
390
+ raise ValidationError(e)
391
+ return
392
+ logger.info("Submitting trade proposal ...")
393
+ self.submit()
394
+ if self.status == TradeProposal.Status.SUBMIT:
395
+ logger.info("Approving trade proposal ...")
396
+ if approve_automatically and self.portfolio.can_be_rebalanced:
397
+ self.approve(replay=False)
398
+
399
+ def replay(self, force_reset_trade: bool = False, broadcast_changes_at_date: bool = True):
309
400
  last_trade_proposal = self
310
401
  last_trade_proposal_created = False
311
402
  while last_trade_proposal and last_trade_proposal.status == TradeProposal.Status.APPROVED:
312
403
  if not last_trade_proposal_created:
313
404
  logger.info(f"Replaying trade proposal {last_trade_proposal}")
314
405
  last_trade_proposal.portfolio.assets.filter(
315
- date=last_trade_proposal.trade_date
316
- ).delete() # we delete the existing position and we reapply the trade proposal
317
- if last_trade_proposal.status == TradeProposal.Status.APPROVED:
318
- logger.info("Reverting trade proposal ...")
319
- last_trade_proposal.revert()
320
- if last_trade_proposal.status == TradeProposal.Status.DRAFT:
321
- 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
322
- logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
323
- with suppress(
324
- ValidationError
325
- ): # 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
326
- self.reset_trades()
327
- logger.info("Submitting trade proposal ...")
328
- last_trade_proposal.submit()
329
- if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
330
- logger.info("Approving trade proposal ...")
331
- last_trade_proposal.approve(replay=False)
406
+ date=self.trade_date
407
+ ).all().delete() # we delete the existing position and we reapply the trade proposal
408
+ last_trade_proposal.approve_workflow(silent_exception=True, force_reset_trade=force_reset_trade)
332
409
  last_trade_proposal.save()
410
+ if last_trade_proposal.status != TradeProposal.Status.APPROVED:
411
+ break
333
412
  next_trade_proposal = last_trade_proposal.next_trade_proposal
334
-
335
- next_trade_date = (
336
- next_trade_proposal.trade_date - timedelta(days=1) if next_trade_proposal else date.today()
337
- )
413
+ if next_trade_proposal:
414
+ next_trade_date = next_trade_proposal.trade_date - timedelta(days=1)
415
+ elif next_expected_rebalancing_date := self.portfolio.get_next_rebalancing_date(
416
+ last_trade_proposal.trade_date
417
+ ):
418
+ next_trade_date = (
419
+ next_expected_rebalancing_date + timedelta(days=7)
420
+ ) # we don't know yet if rebalancing is valid and can be executed on `next_expected_rebalancing_date`, so we add safety window of 7 days
421
+ else:
422
+ next_trade_date = date.today()
423
+ next_trade_date = min(next_trade_date, date.today())
338
424
  positions, overriding_trade_proposal = self.portfolio.drift_weights(
339
- last_trade_proposal.trade_date, next_trade_date
425
+ last_trade_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
340
426
  )
341
- self.portfolio.assets.filter(
342
- date__gt=last_trade_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
343
- ).update(
344
- is_estimated=True
345
- ) # ensure that we reset non estimated position leftover to estimated between trade proposal during replay
427
+
428
+ # self.portfolio.assets.filter(
429
+ # date__gt=last_trade_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
430
+ # ).update(
431
+ # is_estimated=True
432
+ # ) # ensure that we reset non estimated position leftover to estimated between trade proposal during replay
346
433
  self.portfolio.bulk_create_positions(
347
- positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False
434
+ positions,
435
+ delete_leftovers=True,
436
+ compute_metrics=False,
437
+ broadcast_changes_at_date=broadcast_changes_at_date,
438
+ evaluate_rebalancer=False,
348
439
  )
349
440
  if overriding_trade_proposal:
350
441
  last_trade_proposal_created = True
@@ -353,7 +444,20 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
353
444
  last_trade_proposal_created = False
354
445
  last_trade_proposal = next_trade_proposal
355
446
 
356
- def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal) -> Decimal:
447
+ def invalidate_future_trade_proposal(self):
448
+ # Delete all future automatic trade proposals and set the manual one into a draft state
449
+ self.portfolio.trade_proposals.filter(
450
+ trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
451
+ ).delete()
452
+ for future_trade_proposal in self.portfolio.trade_proposals.filter(
453
+ trade_date__gt=self.trade_date, status=TradeProposal.Status.APPROVED
454
+ ):
455
+ future_trade_proposal.revert()
456
+ future_trade_proposal.save()
457
+
458
+ def get_estimated_shares(
459
+ self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal
460
+ ) -> Decimal | None:
357
461
  """
358
462
  Estimates the number of shares for a trade based on the given weight and underlying quote.
359
463
 
@@ -574,7 +678,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
574
678
 
575
679
  Trade.objects.bulk_update(trades, ["status"])
576
680
  self.portfolio.bulk_create_positions(
577
- AssetPositionIterator(self.portfolio).add(assets), evaluate_rebalancer=False, force_save=True
681
+ AssetPositionIterator(self.portfolio).add(assets, is_estimated=False),
682
+ evaluate_rebalancer=False,
683
+ force_save=True,
578
684
  )
579
685
  if replay and self.portfolio.is_manageable:
580
686
  replay_as_task.delay(self.id, user_id=by.id if by else None)
@@ -687,8 +793,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
687
793
  ).delete() # we delete the existing portfolio as it has been reverted
688
794
  for trade in self.trades.all():
689
795
  trade.status = Trade.Status.DRAFT
796
+ trade.drift_factor = Decimal("1")
690
797
  trades.append(trade)
691
- Trade.objects.bulk_update(trades, ["status"])
798
+ Trade.objects.bulk_update(trades, ["status", "drift_factor"])
692
799
 
693
800
  def can_revert(self):
694
801
  errors = dict()
@@ -717,6 +824,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
717
824
  return "{{_portfolio.name}} ({{trade_date}})"
718
825
 
719
826
 
827
+ @receiver(post_save, sender="wbportfolio.TradeProposal")
828
+ def post_fail_trade_proposal(sender, instance: TradeProposal, created, raw, **kwargs):
829
+ # 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
830
+ if not raw and instance.status == TradeProposal.Status.FAILED:
831
+ # we delete all trade proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
832
+ instance.invalidate_future_trade_proposal()
833
+ instance.invalidate_future_trade_proposal()
834
+
835
+
720
836
  @shared_task(queue="portfolio")
721
837
  def replay_as_task(trade_proposal_id, user_id: int | None = None):
722
838
  trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
@@ -53,7 +53,7 @@ class TradeQueryset(OrderedModelQuerySet):
53
53
  .order_by("-date")
54
54
  .values("date")[:1]
55
55
  ),
56
- _previous_weight=Coalesce(
56
+ previous_weight=Coalesce(
57
57
  Subquery(
58
58
  AssetPosition.unannotated_objects.filter(
59
59
  underlying_quote=OuterRef("underlying_instrument"),
@@ -66,8 +66,10 @@ class TradeQueryset(OrderedModelQuerySet):
66
66
  ),
67
67
  Decimal(0),
68
68
  ),
69
- effective_weight=Round(F("_previous_weight") * F("drift_factor"), precision=8),
70
- target_weight=Round(F("effective_weight") + F("weighting"), precision=8),
69
+ effective_weight=Round(
70
+ F("previous_weight") * F("drift_factor"), precision=Trade.TRADE_WEIGHTING_PRECISION
71
+ ),
72
+ target_weight=Round(F("effective_weight") + F("weighting"), precision=Trade.TRADE_WEIGHTING_PRECISION),
71
73
  effective_shares=Coalesce(
72
74
  Subquery(
73
75
  AssetPosition.objects.filter(
@@ -125,6 +127,9 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
125
127
  import_export_handler_class = TradeImportHandler
126
128
 
127
129
  TRADE_WINDOW_INTERVAL = 7
130
+ TRADE_WEIGHTING_PRECISION = (
131
+ 8 # we need to match the assetposition weighting. Skfolio advices using a even smaller number (5)
132
+ )
128
133
 
129
134
  class Status(models.TextChoices):
130
135
  DRAFT = "DRAFT", "Draft"
@@ -165,7 +170,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
165
170
 
166
171
  weighting = models.DecimalField(
167
172
  max_digits=9,
168
- decimal_places=8,
173
+ decimal_places=TRADE_WEIGHTING_PRECISION,
169
174
  default=Decimal(0),
170
175
  help_text="The weight to be multiplied against the target",
171
176
  verbose_name="Weight",
@@ -226,7 +231,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
226
231
  )
227
232
  drift_factor = models.DecimalField(
228
233
  max_digits=16,
229
- decimal_places=8,
234
+ decimal_places=TRADE_WEIGHTING_PRECISION,
230
235
  default=Decimal(1.0),
231
236
  verbose_name="Drift Factor",
232
237
  help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
@@ -324,7 +329,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
324
329
  # we fall back to the latest price before t0
325
330
  return (
326
331
  InstrumentPrice.objects.filter_only_valid_prices()
327
- .filter(instrument=self.underlying_instrument, date__lte=self.value_date)
332
+ .filter(instrument=self.underlying_instrument, date__lte=self.transaction_date)
328
333
  .latest("date")
329
334
  )
330
335
 
@@ -468,7 +473,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
468
473
  return self.last_effective_date
469
474
  elif (
470
475
  assets := AssetPosition.unannotated_objects.filter(
471
- underlying_quote=self.underlying_instrument,
472
476
  date__lte=self.value_date,
473
477
  portfolio=self.portfolio,
474
478
  )
@@ -477,15 +481,21 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
477
481
 
478
482
  @property
479
483
  @admin.display(description="Effective Weight")
480
- def _effective_weight(self) -> Decimal:
481
- if hasattr(self, "effective_weight"):
482
- return self.effective_weight
483
- previous_weight = AssetPosition.unannotated_objects.filter(
484
+ def _previous_weight(self) -> Decimal:
485
+ if hasattr(self, "previous_weight"):
486
+ return self.previous_weight
487
+ return AssetPosition.unannotated_objects.filter(
484
488
  underlying_quote=self.underlying_instrument,
485
489
  date=self._last_effective_date,
486
490
  portfolio=self.portfolio,
487
491
  ).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
488
- return round(previous_weight * self.drift_factor, 8)
492
+
493
+ @property
494
+ @admin.display(description="Effective Weight")
495
+ def _effective_weight(self) -> Decimal:
496
+ if hasattr(self, "effective_weight"):
497
+ return self.effective_weight
498
+ return round(self._previous_weight * self.drift_factor, self.TRADE_WEIGHTING_PRECISION)
489
499
 
490
500
  @property
491
501
  @admin.display(description="Effective Shares")
@@ -504,7 +514,9 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
504
514
  @property
505
515
  @admin.display(description="Target Weight")
506
516
  def _target_weight(self) -> Decimal:
507
- return getattr(self, "target_weight", round(self._effective_weight + self.weighting, 8))
517
+ return getattr(
518
+ self, "target_weight", round(self._effective_weight + self.weighting, self.TRADE_WEIGHTING_PRECISION)
519
+ )
508
520
 
509
521
  @property
510
522
  @admin.display(description="Target Shares")
@@ -553,6 +565,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
553
565
  self._set_type()
554
566
 
555
567
  def save(self, *args, **kwargs):
568
+ if abs(self.weighting) < 10e-6:
569
+ self.weighting = Decimal("0")
556
570
  if self.trade_proposal:
557
571
  if not self.underlying_instrument.is_investable_universe:
558
572
  self.underlying_instrument.is_investable_universe = True
@@ -634,9 +648,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
634
648
  return asset
635
649
 
636
650
  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))
651
+ with suppress(ValueError):
652
+ return Decimal.from_float(self.underlying_instrument.get_price(self.transaction_date))
640
653
 
641
654
  def delete(self, **kwargs):
642
655
  pre_collection.send(sender=self.__class__, instance=self)
@@ -646,19 +659,23 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
646
659
  ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
647
660
  return f"{ticker}{self.shares} ({self.bank})"
648
661
 
649
- def _build_dto(self) -> TradeDTO:
662
+ def _build_dto(self, drift_factor: Decimal = None) -> TradeDTO:
650
663
  """
651
664
  Data Transfer Object
652
665
  Returns:
653
666
  DTO trade object
667
+
654
668
  """
669
+ if not drift_factor:
670
+ drift_factor = self.drift_factor
655
671
  return TradeDTO(
656
672
  id=self.id,
657
673
  underlying_instrument=self.underlying_instrument.id,
658
- effective_weight=self._effective_weight,
659
- target_weight=self._target_weight,
674
+ previous_weight=self._previous_weight,
675
+ target_weight=self._previous_weight * drift_factor + self.weighting,
660
676
  effective_shares=self._effective_shares,
661
677
  target_shares=self._target_shares,
678
+ drift_factor=drift_factor,
662
679
  currency_fx_rate=self.currency_fx_rate,
663
680
  price=self.price,
664
681
  instrument_type=self.underlying_instrument.security_instrument_type.id,
@@ -25,12 +25,11 @@ class Portfolio(BasePortfolio):
25
25
  """
26
26
  returns = self.X.iloc[-1, :].T
27
27
  weights = self.all_weights_per_observation.iloc[-1, :].T
28
- if weights.sum() != 0:
29
- weights /= weights.sum()
30
- contribution = weights * (returns + 1.0)
31
- if contribution.sum() != 0:
32
- contribution /= contribution.sum()
33
- return contribution.dropna().to_dict()
28
+ portfolio_returns = (weights * (returns + 1.0)).sum()
29
+ next_weights = weights * (returns + 1.0) / portfolio_returns
30
+ next_weights = next_weights.dropna()
31
+ next_weights = next_weights / next_weights.sum()
32
+ return next_weights.to_dict()
34
33
 
35
34
  def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
36
35
  expected_returns = self.weights @ self.X.iloc[-1, :].T
@@ -86,7 +86,6 @@ class TradingService:
86
86
  self.trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio).normalize(
87
87
  total_target_weight
88
88
  )
89
-
90
89
  self._effective_portfolio = effective_portfolio
91
90
  self._target_portfolio = target_portfolio
92
91
 
@@ -144,34 +143,32 @@ class TradingService:
144
143
  trades: list[Trade] = []
145
144
  for instrument_id, pos in instruments.items():
146
145
  if not pos.is_cash:
147
- effective_weight = target_weight = 0
146
+ previous_weight = target_weight = 0
148
147
  effective_shares = target_shares = 0
149
148
  drift_factor = 1.0
150
149
  if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
151
- effective_weight = effective_pos.weighting
150
+ previous_weight = effective_pos.weighting
152
151
  effective_shares = effective_pos.shares
153
152
  drift_factor = effective_pos.drift_factor
154
153
  if target_pos := target_portfolio.positions_map.get(instrument_id, None):
155
154
  target_weight = target_pos.weighting
156
155
  if target_pos.shares is not None:
157
156
  target_shares = target_pos.shares
158
-
159
- trades.append(
160
- Trade(
161
- underlying_instrument=instrument_id,
162
- effective_weight=effective_weight,
163
- target_weight=target_weight,
164
- effective_shares=effective_shares,
165
- target_shares=target_shares,
166
- date=self.trade_date,
167
- instrument_type=pos.instrument_type,
168
- currency=pos.currency,
169
- price=Decimal(pos.price),
170
- currency_fx_rate=Decimal(pos.currency_fx_rate),
171
- drift_factor=Decimal(drift_factor),
172
- )
157
+ trade = Trade(
158
+ underlying_instrument=instrument_id,
159
+ previous_weight=previous_weight,
160
+ target_weight=target_weight,
161
+ effective_shares=effective_shares,
162
+ target_shares=target_shares,
163
+ date=self.trade_date,
164
+ instrument_type=pos.instrument_type,
165
+ currency=pos.currency,
166
+ price=Decimal(pos.price) if pos.price is not None else Decimal("0"),
167
+ currency_fx_rate=Decimal(pos.currency_fx_rate),
168
+ drift_factor=Decimal(drift_factor),
173
169
  )
174
- return TradeBatch(tuple(trades))
170
+ trades.append(trade)
171
+ return TradeBatch(trades)
175
172
 
176
173
  def is_valid(self, ignore_error: bool = False) -> bool:
177
174
  """