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

@@ -1,10 +1,9 @@
1
1
  import logging
2
- from collections import defaultdict
3
2
  from contextlib import suppress
4
3
  from datetime import date, timedelta
5
4
  from decimal import Decimal
6
5
  from math import isclose
7
- from typing import Any, Iterable
6
+ from typing import TYPE_CHECKING, Any, Iterable
8
7
 
9
8
  import numpy as np
10
9
  import pandas as pd
@@ -19,6 +18,7 @@ from django.db.models import (
19
18
  Q,
20
19
  QuerySet,
21
20
  Sum,
21
+ Value,
22
22
  )
23
23
  from django.db.models.signals import post_save
24
24
  from django.dispatch import receiver
@@ -36,7 +36,7 @@ from wbfdm.models import Instrument, InstrumentType
36
36
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
37
37
  from wbfdm.signals import investable_universe_updated
38
38
 
39
- from wbportfolio.models.asset import AssetPosition
39
+ from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
40
40
  from wbportfolio.models.indexes import Index
41
41
  from wbportfolio.models.portfolio_relationship import (
42
42
  InstrumentPortfolioThroughModel,
@@ -50,68 +50,56 @@ from . import ProductGroup
50
50
  from .exceptions import InvalidAnalyticPortfolio
51
51
 
52
52
  logger = logging.getLogger("pms")
53
-
54
-
55
- class PositionDictConverter:
56
- """Utility class to load a dictionary of dictionary (dictionary of instrument_id and float per date) into a iterable of assetposition"""
57
-
58
- def __init__(self, portfolio, prices: pd.DataFrame, infer_underlying_quote_price=False):
59
- self.portfolio = portfolio
60
- self.instruments = {}
61
- self.fx_rates = defaultdict(dict)
62
- self.prices = prices
63
- self.infer_underlying_quote_price = infer_underlying_quote_price
64
-
65
- def _get_instrument(self, instrument_id: int) -> Instrument:
66
- try:
67
- return self.instruments[instrument_id]
68
- except KeyError:
69
- instrument = Instrument.objects.get(id=instrument_id)
70
- self.instruments[instrument_id] = instrument
71
- return instrument
72
-
73
- def _get_fx_rate(self, val_date: date, currency) -> CurrencyFXRates:
74
- try:
75
- return self.fx_rates[currency][val_date]
76
- except KeyError:
77
- fx_rate = CurrencyFXRates.objects.get_or_create(
78
- currency=currency, date=val_date, defaults={"value": Decimal(0)}
79
- )[0] # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
80
- self.fx_rates[currency][val_date] = fx_rate
81
- return fx_rate
82
-
83
- def dict_to_model(self, val_date: date, instrument_id: int, weighting: float) -> AssetPosition | None:
84
- underlying_quote = self._get_instrument(instrument_id)
85
- currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
86
- currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
87
- initial_price = self.prices.loc[pd.Timestamp(val_date), instrument_id]
88
- # if we cannot find a price for the instrument, we return an empty position
89
- if initial_price is not None:
90
- position = AssetPosition(
91
- underlying_quote=underlying_quote,
92
- weighting=weighting,
93
- date=val_date,
94
- asset_valuation_date=val_date,
95
- is_estimated=True,
96
- portfolio=self.portfolio,
97
- currency=underlying_quote.currency,
98
- initial_price=self.prices.loc[pd.Timestamp(val_date), instrument_id],
99
- currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
100
- currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
101
- initial_currency_fx_rate=None,
102
- underlying_quote_price=None,
103
- underlying_instrument=None,
104
- )
105
- position.pre_save(
106
- infer_underlying_quote_price=self.infer_underlying_quote_price
107
- ) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
108
- return position
109
-
110
- def convert(self, positions: dict[date, dict[int, float]]) -> Iterable[AssetPosition]:
111
- for val_date, weights in positions.items():
112
- yield from filter(
113
- lambda x: x, map(lambda row: self.dict_to_model(val_date, row[0], row[1]), weights.items())
114
- )
53
+ if TYPE_CHECKING:
54
+ from wbportfolio.models.transactions.trade_proposals import TradeProposal
55
+
56
+
57
+ def to_dict(df: pd.DataFrame) -> dict[date, dict[int, float]]:
58
+ return {ts.date(): row for ts, row in df.to_dict("index").items()}
59
+
60
+
61
+ def get_returns(
62
+ instrument_ids: list[int],
63
+ from_date: date,
64
+ to_date: date,
65
+ to_currency: Currency | None = None,
66
+ ffill_returns: bool = True,
67
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
68
+ """
69
+ Utility methods to get instrument returns for a given date range
70
+
71
+ Args:
72
+ from_date: date range lower bound
73
+ to_date: date range upper bound
74
+
75
+ Returns:
76
+ Return a tuple of the returns and the last prices series for conveniance
77
+ """
78
+ if to_currency:
79
+ fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
80
+ else:
81
+ fx_rate = Value(Decimal(1.0))
82
+ prices = InstrumentPrice.objects.filter(
83
+ instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
84
+ ).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
85
+ prices_df = (
86
+ pd.DataFrame(
87
+ prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
88
+ columns=["instrument", "price_fx_portfolio", "date"],
89
+ )
90
+ .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
91
+ .astype(float)
92
+ .sort_index()
93
+ )
94
+ if prices_df.empty:
95
+ raise InvalidAnalyticPortfolio()
96
+ ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
97
+ prices_df = prices_df.reindex(ts)
98
+ if ffill_returns:
99
+ prices_df = prices_df.ffill()
100
+ prices_df.index = pd.to_datetime(prices_df.index)
101
+ returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
102
+ return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df.replace([np.inf, -np.inf, np.nan], None)
115
103
 
116
104
 
117
105
  class DefaultPortfolioQueryset(QuerySet):
@@ -185,7 +173,7 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
185
173
  super()
186
174
  .get_queryset()
187
175
  .annotate(asset_exists=Exists(AssetPosition.unannotated_objects.filter(portfolio=OuterRef("pk"))))
188
- .filter(asset_exists=True, is_tracked=True)
176
+ .filter(Q(asset_exists=True) & (Q(is_tracked=True) | Q(is_manageable=True)))
189
177
  )
190
178
 
191
179
 
@@ -337,7 +325,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
337
325
 
338
326
  @property
339
327
  def can_be_rebalanced(self):
340
- return self.is_tracked and self.is_manageable and not self.is_lookthrough
328
+ return self.is_manageable and not self.is_lookthrough
341
329
 
342
330
  def delete(self, **kwargs):
343
331
  super().delete(**kwargs)
@@ -374,7 +362,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
374
362
  )
375
363
  )
376
364
 
377
- def get_analytic_portfolio(self, val_date: date, **kwargs) -> tuple[AnalyticPortfolio, pd.DataFrame]:
365
+ def get_analytic_portfolio(
366
+ self, val_date: date, weights: dict[int, float] | None = None, **kwargs
367
+ ) -> tuple[AnalyticPortfolio, pd.DataFrame]:
378
368
  """
379
369
  Return the analytic portfolio associated with this portfolio at the given date
380
370
 
@@ -385,9 +375,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
385
375
  Returns:
386
376
  The instantiated analytic portfolio
387
377
  """
388
- weights = self.get_weights(val_date)
378
+ if not weights:
379
+ weights = self.get_weights(val_date)
389
380
  return_date = (val_date + BDay(1)).date()
390
- returns, prices = self.get_returns((val_date - BDay(2)).date(), return_date, **kwargs)
381
+ returns, prices = get_returns(
382
+ list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
383
+ )
391
384
  if pd.Timestamp(return_date) not in returns.index:
392
385
  raise InvalidAnalyticPortfolio()
393
386
  returns = returns.fillna(0) # not sure this is what we want
@@ -682,6 +675,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
682
675
  force_recompute_weighting: bool = False,
683
676
  compute_metrics: bool = False,
684
677
  evaluate_rebalancer: bool = True,
678
+ changed_weights: dict[int, float] | None = None,
685
679
  ):
686
680
  logger.info(f"change at date for {self} at {val_date}")
687
681
 
@@ -709,7 +703,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
709
703
  asset.save()
710
704
 
711
705
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
712
- self.estimate_net_asset_values((val_date + BDay(1)).date()) # updating weighting in t0 influence nav in t+1
706
+ self.estimate_net_asset_values(
707
+ (val_date + BDay(1)).date(), weights=changed_weights
708
+ ) # updating weighting in t0 influence nav in t+1
713
709
  if evaluate_rebalancer:
714
710
  self.evaluate_rebalancing(val_date)
715
711
 
@@ -731,13 +727,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
731
727
  for rel in PortfolioPortfolioThroughModel.objects.filter(
732
728
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
733
729
  ):
734
- lookthrough_portfolio = rel.portfolio
735
- portfolio_total_asset_value = (
736
- self.get_total_asset_under_management(val_date) if not lookthrough_portfolio.only_weighting else None
737
- )
738
- lookthrough_portfolio.compute_lookthrough(
739
- val_date, portfolio_total_asset_value=portfolio_total_asset_value
740
- )
730
+ rel.portfolio.compute_lookthrough(val_date)
741
731
  for rel in PortfolioPortfolioThroughModel.objects.filter(
742
732
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
743
733
  ):
@@ -757,14 +747,14 @@ class Portfolio(DeleteToDisableMixin, WBModel):
757
747
  logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
758
748
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
759
749
 
760
- def estimate_net_asset_values(self, val_date: date):
750
+ def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
761
751
  for instrument in self.pms_instruments:
762
752
  if instrument.is_active_at_date(val_date) and (
763
753
  net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
764
754
  ):
765
755
  logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
766
756
  net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
767
- estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument)
757
+ estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
768
758
  if estimated_net_asset_value is not None:
769
759
  InstrumentPrice.objects.update_or_create(
770
760
  instrument=instrument,
@@ -780,40 +770,57 @@ class Portfolio(DeleteToDisableMixin, WBModel):
780
770
  ): # if price date is the latest instrument price date, we recompute the last valuation data
781
771
  instrument.update_last_valuation_date()
782
772
 
783
- def batch_portfolio(self, start_date: date, end_date: date):
784
- """
785
- Create the cumulative portfolios between the two given dates and stop at the first rebalancing (if any)
786
-
787
- Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
788
- """
773
+ def drift_weights(self, start_date: date, end_date: date) -> tuple[AssetPositionIterator, "TradeProposal"]:
774
+ logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
789
775
  rebalancer = getattr(self, "automatic_rebalancer", None)
790
-
776
+ # Get initial weights
791
777
  weights = self.get_weights(start_date) # initial weights
792
- positions = dict()
793
- next_trade_proposal = None
794
- rebalancing_date = None
795
- logger.info(f"compute next weights in batch for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
796
- returns, prices = self.get_returns((start_date - BDay(3)).date(), end_date, ffill_returns=True)
778
+ if not weights:
779
+ previous_date = self.assets.filter(date__lte=start_date).latest("date").date
780
+ drifted_positions, _ = self.drift_weights(previous_date, start_date)
781
+ weights = drifted_positions.get_weights()[start_date]
782
+
783
+ # Get returns and prices data for the whole date range
784
+ returns, prices = get_returns(
785
+ list(weights.keys()),
786
+ (start_date - BDay(3)).date(),
787
+ end_date,
788
+ to_currency=self.currency,
789
+ ffill_returns=True,
790
+ )
791
+ # Instantiate the position iterator with the initial weights
792
+ positions = AssetPositionIterator(self, prices=to_dict(prices))
793
+ last_trade_proposal = None
797
794
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
798
795
  to_date = to_date_ts.date()
796
+ to_is_active = self.is_active_at_date(to_date)
799
797
  logger.info(f"Processing {to_date:%Y-%m-%d}")
800
798
  if rebalancer and rebalancer.is_valid(to_date):
801
- rebalancing_date = to_date
802
- break
803
- try:
804
- last_returns = returns.loc[[to_date_ts], :]
805
- analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
806
- weights = analytic_portfolio.get_next_weights()
807
- positions[to_date] = weights
808
- except KeyError: # if no return for that date, we break and continue
799
+ last_trade_proposal = rebalancer.evaluate_rebalancing(to_date)
800
+ # if trade proposal/rebalancing is not approved, we cannot continue the drift
801
+ if last_trade_proposal.status != last_trade_proposal.Status.APPROVED:
802
+ break
803
+ target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
804
+ next_weights = {
805
+ underlying_quote_id: float(pos.weighting)
806
+ for underlying_quote_id, pos in target_portfolio.positions_map.items()
807
+ }
808
+ else:
809
+ try:
810
+ last_returns = returns.loc[[to_date_ts], :]
811
+ analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
812
+ next_weights = analytic_portfolio.get_next_weights()
813
+ except KeyError: # if no return for that date, we break and continue
814
+ next_weights = weights
815
+ if to_is_active:
816
+ positions.add((to_date, next_weights))
817
+ else:
818
+ positions.add(
819
+ (to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
820
+ ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
809
821
  break
810
- positions_generator = PositionDictConverter(self, prices)
811
- positions = list(positions_generator.convert(positions))
812
- self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False)
813
- if rebalancing_date:
814
- next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
815
-
816
- return next_trade_proposal
822
+ weights = next_weights
823
+ return positions, last_trade_proposal
817
824
 
818
825
  def propagate_or_update_assets(self, from_date: date, to_date: date):
819
826
  """
@@ -824,33 +831,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
824
831
  to_date: The date to create the new portfolio at
825
832
 
826
833
  """
827
- from_is_active = self.is_active_at_date(from_date)
828
- to_is_active = self.is_active_at_date(to_date)
829
-
830
- def _parse_position(asset: AssetPosition) -> AssetPosition:
831
- # function to handle the position modification after instantiation
832
- if from_is_active and not to_is_active:
833
- asset.weighting = Decimal(0.0)
834
- asset.initial_shares = AssetPosition.unannotated_objects.filter(
835
- date=from_date, underlying_quote=asset.underlying_quote, portfolio=self
836
- ).aggregate(sum_shares=Sum("initial_shares"))["sum_shares"]
837
- return asset
838
-
839
834
  # we don't propagate on already imported portfolio by default
840
835
  is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
841
836
  if (
842
- self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
837
+ not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
843
838
  ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
844
- logger.info(f"computing next weight for {self} from {from_date:%Y-%m-%d} to {to_date:%Y-%m-%d}")
845
- with suppress(InvalidAnalyticPortfolio):
846
- analytic_portfolio, to_prices = self.get_analytic_portfolio(from_date)
847
- if not to_prices.empty:
848
- weights = analytic_portfolio.get_next_weights()
849
- positions_generator = PositionDictConverter(
850
- self, to_prices, infer_underlying_quote_price=True
851
- ).convert({to_date: weights})
852
- positions = list(map(lambda a: _parse_position(a), positions_generator))
853
- self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
839
+ positions, _ = self.drift_weights(from_date, to_date)
840
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
854
841
 
855
842
  def get_lookthrough_positions(
856
843
  self,
@@ -922,27 +909,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
922
909
  assets = self.assets.filter(date=val_date)
923
910
  return assets
924
911
 
925
- def compute_lookthrough(self, sync_date: date, portfolio_total_asset_value: Decimal | None = None):
912
+ def compute_lookthrough(self, from_date: date, to_date: date | None = None):
926
913
  if not self.primary_portfolio or not self.is_lookthrough:
927
914
  raise ValueError(
928
915
  "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
929
916
  )
930
- logger.info(f"Compute Look-Through for {self} at {sync_date}")
931
- positions = self.primary_portfolio.get_lookthrough_positions(sync_date, portfolio_total_asset_value)
932
- self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=True)
933
-
934
- def batch_recompute_lookthrough(self, from_date: date, to_date: date):
935
- if not self.primary_portfolio or not self.is_lookthrough:
936
- raise ValueError(
937
- "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
938
- )
939
- positions = []
940
- for val_date in pd.date_range(from_date, to_date, freq="B").date:
917
+ positions = AssetPositionIterator(self)
918
+ if not to_date:
919
+ to_date = from_date
920
+ for from_date in pd.date_range(from_date, to_date, freq="B").date:
921
+ logger.info(f"Compute Look-Through for {self} at {from_date}")
941
922
  portfolio_total_asset_value = (
942
- self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
923
+ self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
924
+ )
925
+ positions.add(
926
+ list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
943
927
  )
944
- positions.extend(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value))
945
- self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=False)
928
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
946
929
 
947
930
  def update_preferred_classification_per_instrument(self):
948
931
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -999,47 +982,50 @@ class Portfolio(DeleteToDisableMixin, WBModel):
999
982
  def get_representation_label_key(cls):
1000
983
  return "{{name}}"
1001
984
 
1002
- def bulk_create_positions(self, positions: list[AssetPosition], delete_leftovers: bool = False, **kwargs):
985
+ def bulk_create_positions(
986
+ self, positions: AssetPositionIterator, delete_leftovers: bool = False, force_save: bool = False, **kwargs
987
+ ):
1003
988
  if positions:
1004
- update_dates = set()
1005
- for position in positions:
1006
- position.portfolio = self
1007
- update_dates.add(position.date)
1008
-
1009
989
  # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
1010
990
  # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
1011
991
  # change completely the trades of a portfolio model and drift it.
1012
- self.assets.filter(date__in=update_dates, is_estimated=True).delete()
1013
- leftover_positions_ids = list(
1014
- self.assets.filter(date__in=update_dates).values_list("id", flat=True)
1015
- ) # we need to get the ids otherwise the queryset is reevaluated later
1016
- logger.info(f"bulk saving {len(positions)} positions ({len(leftover_positions_ids)} leftovers) ...")
1017
- objs = AssetPosition.unannotated_objects.bulk_create(
1018
- positions,
1019
- update_fields=[
1020
- "weighting",
1021
- "initial_price",
1022
- "initial_currency_fx_rate",
1023
- "initial_shares",
1024
- "currency_fx_rate_instrument_to_usd",
1025
- "currency_fx_rate_portfolio_to_usd",
1026
- "underlying_quote_price",
1027
- "is_estimated",
1028
- "portfolio",
1029
- "portfolio_created",
1030
- "underlying_instrument",
1031
- ],
1032
- unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
1033
- update_conflicts=True,
1034
- batch_size=10000,
1035
- )
1036
- if delete_leftovers:
1037
- objs_ids = list(map(lambda x: x.id, objs))
1038
- leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
1039
- logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
1040
- AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
1041
- for update_date in sorted(update_dates):
1042
- self.change_at_date(update_date, **kwargs)
992
+
993
+ dates = positions.get_dates()
994
+ self.assets.filter(date__in=dates, is_estimated=True).delete()
995
+
996
+ if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
997
+ leftover_positions_ids = list(
998
+ self.assets.filter(date__in=dates).values_list("id", flat=True)
999
+ ) # we need to get the ids otherwise the queryset is reevaluated later
1000
+ positions_list = list(positions)
1001
+ logger.info(
1002
+ f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
1003
+ )
1004
+ objs = AssetPosition.unannotated_objects.bulk_create(
1005
+ positions_list,
1006
+ update_fields=[
1007
+ "weighting",
1008
+ "initial_price",
1009
+ "initial_currency_fx_rate",
1010
+ "initial_shares",
1011
+ "currency_fx_rate_instrument_to_usd",
1012
+ "currency_fx_rate_portfolio_to_usd",
1013
+ "underlying_quote_price",
1014
+ "portfolio",
1015
+ "portfolio_created",
1016
+ "underlying_instrument",
1017
+ ],
1018
+ unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
1019
+ update_conflicts=True,
1020
+ batch_size=10000,
1021
+ )
1022
+ if delete_leftovers:
1023
+ objs_ids = list(map(lambda x: x.id, objs))
1024
+ leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
1025
+ logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
1026
+ AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
1027
+ for update_date, changed_weights in positions.get_weights().items():
1028
+ self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
1043
1029
 
1044
1030
  @classmethod
1045
1031
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
@@ -1053,54 +1039,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1053
1039
  def check_share_diff(self, val_date: date) -> bool:
1054
1040
  return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
1055
1041
 
1056
- def get_returns(
1057
- self,
1058
- from_date: date,
1059
- to_date: date,
1060
- ffill_returns: bool = True,
1061
- convert_to_portfolio_currency: bool = True,
1062
- ) -> tuple[pd.DataFrame, pd.DataFrame]:
1063
- """
1064
- Utility methods to get instrument returns for a given date range
1065
-
1066
- Args:
1067
- from_date: date range lower bound
1068
- to_date: date range upper bound
1069
-
1070
- Returns:
1071
- Return a tuple of the returns and the last prices series for conveniance
1072
- """
1073
- instruments = list(
1074
- self.assets.filter(date__gte=from_date, date__lte=to_date)
1075
- .values_list("underlying_quote", flat=True)
1076
- .distinct("underlying_quote")
1077
- )
1078
- prices = InstrumentPrice.objects.filter(
1079
- instrument__in=instruments, date__gte=from_date, date__lte=to_date
1080
- ).annotate(
1081
- fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies(
1082
- "date", "instrument__currency", self.currency
1083
- ),
1084
- price_fx_portfolio=F("net_value") * F("fx_rate") if convert_to_portfolio_currency else F("net_value"),
1085
- )
1086
- prices_df = (
1087
- pd.DataFrame(
1088
- prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
1089
- columns=["instrument", "price_fx_portfolio", "date"],
1090
- )
1091
- .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
1092
- .astype(float)
1093
- .sort_index()
1094
- )
1095
- if prices_df.empty:
1096
- raise InvalidAnalyticPortfolio()
1097
- ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
1098
- prices_df = prices_df.reindex(ts)
1099
- if ffill_returns:
1100
- prices_df = prices_df.ffill()
1101
- returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
1102
- return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df.replace([np.inf, -np.inf, np.nan], None)
1103
-
1104
1042
  @classmethod
1105
1043
  def get_contribution_df(cls, data, need_normalize: bool = False):
1106
1044
  df = pd.DataFrame(
@@ -1241,20 +1179,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1241
1179
  return portfolio
1242
1180
 
1243
1181
 
1244
- def default_estimate_net_value(val_date: date, instrument: Instrument) -> float | None:
1245
- portfolio = instrument.portfolio
1182
+ def default_estimate_net_value(
1183
+ val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
1184
+ ) -> float | None:
1185
+ portfolio: Portfolio = instrument.portfolio
1246
1186
  previous_val_date = (val_date - BDay(1)).date()
1247
-
1248
- if (
1249
- portfolio.assets.filter(date=previous_val_date).exists()
1250
- and ( # we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
1251
- last_price := instrument.get_latest_price(previous_val_date)
1252
- )
1253
- ):
1187
+ if not weights:
1188
+ weights = portfolio.get_weights(previous_val_date)
1189
+ # we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
1190
+ if weights and (last_price := instrument.get_latest_price(previous_val_date)):
1254
1191
  with suppress(
1255
1192
  IndexError, InvalidAnalyticPortfolio
1256
1193
  ): # we silent any indexerror introduced by no returns for the past days
1257
- analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_val_date)
1194
+ analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
1258
1195
  return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1259
1196
 
1260
1197
 
@@ -1268,9 +1205,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
1268
1205
  ):
1269
1206
  with suppress(AssetPosition.DoesNotExist):
1270
1207
  earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
1271
- batch_recompute_lookthrough_as_task.delay(
1272
- instance.portfolio.id, earliest_primary_position_date, date.today()
1273
- )
1208
+ compute_lookthrough_as_task.delay(instance.portfolio.id, earliest_primary_position_date, date.today())
1274
1209
 
1275
1210
 
1276
1211
  @shared_task(queue="portfolio")
@@ -1280,9 +1215,9 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
1280
1215
 
1281
1216
 
1282
1217
  @shared_task(queue="portfolio")
1283
- def batch_recompute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
1218
+ def compute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
1284
1219
  portfolio = Portfolio.objects.get(id=portfolio_id)
1285
- portfolio.batch_recompute_lookthrough(start, end)
1220
+ portfolio.compute_lookthrough(start, to_date=end)
1286
1221
 
1287
1222
 
1288
1223
  @receiver(investable_universe_updated, sender="wbfdm.Instrument")
@@ -76,11 +76,11 @@ class DailyPortfolioCashFlow(ImportMixin, WBModel):
76
76
 
77
77
  with suppress(self.DoesNotExist):
78
78
  if self.total_assets is None or self.pending:
79
- self.total_assets = (
80
- self.portfolio.daily_cashflows.filter(value_date__lt=self.value_date)
81
- .latest("value_date")
82
- .estimated_total_assets
83
- )
79
+ prev = self.portfolio.daily_cashflows.filter(value_date__lt=self.value_date).latest("value_date")
80
+ if prev.pending:
81
+ self.total_assets = prev.estimated_total_assets
82
+ else:
83
+ self.total_assets = prev.total_assets
84
84
 
85
85
  if self.total_assets is None:
86
86
  self.total_assets = 0
@@ -57,11 +57,7 @@ class PortfolioRole(models.Model):
57
57
  ) or self.role_type in [
58
58
  self.RoleType.PORTFOLIO_MANAGER,
59
59
  self.RoleType.ANALYST,
60
- ], self.default_error_messages[
61
- "manager"
62
- ].format(
63
- model="instrument"
64
- )
60
+ ], self.default_error_messages["manager"].format(model="instrument")
65
61
 
66
62
  assert (self.start and self.end and self.start < self.end) or (
67
63
  not self.start or not self.end