wbportfolio 1.49.9__py2.py3-none-any.whl → 1.49.11__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,73 @@ 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 get_prices(instrument_ids: list[int], from_date: date, to_date: date) -> dict[date, dict[int, float]]:
58
+ """
59
+ Utility to fetch raw prices
60
+ """
61
+ prices = InstrumentPrice.objects.filter(instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date)
62
+ df = (
63
+ pd.DataFrame(
64
+ prices.filter_only_valid_prices().values_list("instrument", "net_value", "date"),
65
+ columns=["instrument", "net_value", "date"],
66
+ )
67
+ .pivot_table(index="date", values="net_value", columns="instrument")
68
+ .astype(float)
69
+ .sort_index()
70
+ )
71
+ ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
72
+ df = df.reindex(ts)
73
+ df = df.ffill()
74
+ df.index = pd.to_datetime(df.index)
75
+ return {ts.date(): row for ts, row in df.to_dict("index").items()}
76
+
77
+
78
+ def get_returns(
79
+ instrument_ids: list[int],
80
+ from_date: date,
81
+ to_date: date,
82
+ to_currency: Currency | None = None,
83
+ ffill_returns: bool = True,
84
+ ) -> pd.DataFrame:
85
+ """
86
+ Utility methods to get instrument returns for a given date range
87
+
88
+ Args:
89
+ from_date: date range lower bound
90
+ to_date: date range upper bound
91
+
92
+ Returns:
93
+ Return a tuple of the returns and the last prices series for conveniance
94
+ """
95
+ if to_currency:
96
+ fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
97
+ else:
98
+ fx_rate = Value(Decimal(1.0))
99
+ prices = InstrumentPrice.objects.filter(
100
+ instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
101
+ ).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
102
+ prices_df = (
103
+ pd.DataFrame(
104
+ prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
105
+ columns=["instrument", "price_fx_portfolio", "date"],
106
+ )
107
+ .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
108
+ .astype(float)
109
+ .sort_index()
110
+ )
111
+ if prices_df.empty:
112
+ raise InvalidAnalyticPortfolio()
113
+ ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
114
+ prices_df = prices_df.reindex(ts)
115
+ if ffill_returns:
116
+ prices_df = prices_df.ffill()
117
+ prices_df.index = pd.to_datetime(prices_df.index)
118
+ returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
119
+ return returns.replace([np.inf, -np.inf, np.nan], 0)
115
120
 
116
121
 
117
122
  class DefaultPortfolioQueryset(QuerySet):
@@ -185,7 +190,7 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
185
190
  super()
186
191
  .get_queryset()
187
192
  .annotate(asset_exists=Exists(AssetPosition.unannotated_objects.filter(portfolio=OuterRef("pk"))))
188
- .filter(asset_exists=True, is_tracked=True)
193
+ .filter(Q(asset_exists=True) & (Q(is_tracked=True) | Q(is_manageable=True)))
189
194
  )
190
195
 
191
196
 
@@ -337,7 +342,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
337
342
 
338
343
  @property
339
344
  def can_be_rebalanced(self):
340
- return self.is_tracked and self.is_manageable and not self.is_lookthrough
345
+ return self.is_manageable and not self.is_lookthrough
341
346
 
342
347
  def delete(self, **kwargs):
343
348
  super().delete(**kwargs)
@@ -374,7 +379,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
374
379
  )
375
380
  )
376
381
 
377
- def get_analytic_portfolio(self, val_date: date, **kwargs) -> tuple[AnalyticPortfolio, pd.DataFrame]:
382
+ def get_analytic_portfolio(
383
+ self, val_date: date, weights: dict[int, float] | None = None, **kwargs
384
+ ) -> AnalyticPortfolio:
378
385
  """
379
386
  Return the analytic portfolio associated with this portfolio at the given date
380
387
 
@@ -385,18 +392,18 @@ class Portfolio(DeleteToDisableMixin, WBModel):
385
392
  Returns:
386
393
  The instantiated analytic portfolio
387
394
  """
388
- weights = self.get_weights(val_date)
395
+ if not weights:
396
+ weights = self.get_weights(val_date)
389
397
  return_date = (val_date + BDay(1)).date()
390
- returns, prices = self.get_returns((val_date - BDay(2)).date(), return_date, **kwargs)
398
+ returns = get_returns(
399
+ list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
400
+ )
391
401
  if pd.Timestamp(return_date) not in returns.index:
392
402
  raise InvalidAnalyticPortfolio()
393
403
  returns = returns.fillna(0) # not sure this is what we want
394
- return (
395
- AnalyticPortfolio(
396
- X=returns,
397
- weights=weights,
398
- ),
399
- prices,
404
+ return AnalyticPortfolio(
405
+ X=returns,
406
+ weights=weights,
400
407
  )
401
408
 
402
409
  def is_invested_at_date(self, val_date: date) -> bool:
@@ -682,6 +689,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
682
689
  force_recompute_weighting: bool = False,
683
690
  compute_metrics: bool = False,
684
691
  evaluate_rebalancer: bool = True,
692
+ changed_weights: dict[int, float] | None = None,
685
693
  ):
686
694
  logger.info(f"change at date for {self} at {val_date}")
687
695
 
@@ -709,7 +717,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
709
717
  asset.save()
710
718
 
711
719
  # 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
720
+ self.estimate_net_asset_values(
721
+ (val_date + BDay(1)).date(), weights=changed_weights
722
+ ) # updating weighting in t0 influence nav in t+1
713
723
  if evaluate_rebalancer:
714
724
  self.evaluate_rebalancing(val_date)
715
725
 
@@ -731,13 +741,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
731
741
  for rel in PortfolioPortfolioThroughModel.objects.filter(
732
742
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
733
743
  ):
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
- )
744
+ rel.portfolio.compute_lookthrough(val_date)
741
745
  for rel in PortfolioPortfolioThroughModel.objects.filter(
742
746
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
743
747
  ):
@@ -757,14 +761,14 @@ class Portfolio(DeleteToDisableMixin, WBModel):
757
761
  logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
758
762
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
759
763
 
760
- def estimate_net_asset_values(self, val_date: date):
764
+ def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
761
765
  for instrument in self.pms_instruments:
762
766
  if instrument.is_active_at_date(val_date) and (
763
767
  net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
764
768
  ):
765
769
  logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
766
770
  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)
771
+ estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
768
772
  if estimated_net_asset_value is not None:
769
773
  InstrumentPrice.objects.update_or_create(
770
774
  instrument=instrument,
@@ -780,40 +784,60 @@ class Portfolio(DeleteToDisableMixin, WBModel):
780
784
  ): # if price date is the latest instrument price date, we recompute the last valuation data
781
785
  instrument.update_last_valuation_date()
782
786
 
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
- """
787
+ def drift_weights(self, start_date: date, end_date: date) -> tuple[AssetPositionIterator, "TradeProposal"]:
788
+ logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
789
789
  rebalancer = getattr(self, "automatic_rebalancer", None)
790
-
790
+ # Get initial weights
791
791
  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)
792
+ if not weights:
793
+ previous_date = self.assets.filter(date__lte=start_date).latest("date").date
794
+ drifted_positions, _ = self.drift_weights(previous_date, start_date)
795
+ weights = drifted_positions.get_weights()[start_date]
796
+
797
+ # Get returns and prices data for the whole date range
798
+ instrument_ids = list(weights.keys())
799
+ returns = get_returns(
800
+ instrument_ids,
801
+ (start_date - BDay(3)).date(),
802
+ end_date,
803
+ to_currency=self.currency,
804
+ ffill_returns=True,
805
+ )
806
+ # Get raw prices to speed up asset position creation
807
+ prices = get_prices(instrument_ids, (start_date - BDay(3)).date(), end_date)
808
+ # Instantiate the position iterator with the initial weights
809
+ positions = AssetPositionIterator(self, prices=prices)
810
+ last_trade_proposal = None
797
811
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
798
812
  to_date = to_date_ts.date()
813
+ to_is_active = self.is_active_at_date(to_date)
799
814
  logger.info(f"Processing {to_date:%Y-%m-%d}")
800
815
  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
816
+ last_trade_proposal = rebalancer.evaluate_rebalancing(to_date)
817
+ # if trade proposal/rebalancing is not approved, we cannot continue the drift
818
+ if last_trade_proposal.status != last_trade_proposal.Status.APPROVED:
819
+ break
820
+ target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
821
+ next_weights = {
822
+ underlying_quote_id: float(pos.weighting)
823
+ for underlying_quote_id, pos in target_portfolio.positions_map.items()
824
+ }
825
+ else:
826
+ try:
827
+ last_returns = returns.loc[[to_date_ts], :]
828
+ analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
829
+ next_weights = analytic_portfolio.get_next_weights()
830
+ except KeyError: # if no return for that date, we break and continue
831
+ next_weights = weights
832
+ if to_is_active:
833
+ positions.add((to_date, next_weights))
834
+ else:
835
+ positions.add(
836
+ (to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
837
+ ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
809
838
  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
839
+ weights = next_weights
840
+ return positions, last_trade_proposal
817
841
 
818
842
  def propagate_or_update_assets(self, from_date: date, to_date: date):
819
843
  """
@@ -824,33 +848,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
824
848
  to_date: The date to create the new portfolio at
825
849
 
826
850
  """
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
851
  # we don't propagate on already imported portfolio by default
840
852
  is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
841
853
  if (
842
- self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
854
+ not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
843
855
  ): # 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)
856
+ positions, _ = self.drift_weights(from_date, to_date)
857
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
854
858
 
855
859
  def get_lookthrough_positions(
856
860
  self,
@@ -896,10 +900,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
896
900
 
897
901
  setattr(position, "path", path)
898
902
  position.initial_shares = None
899
- if portfolio_total_asset_value:
900
- position.initial_shares = (position.weighting * portfolio_total_asset_value) / (
901
- position.price * position.currency_fx_rate
902
- )
903
+ if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
904
+ position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
903
905
  if child_portfolio := position.underlying_quote.primary_portfolio:
904
906
  if with_intermediary_position:
905
907
  yield position
@@ -922,27 +924,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
922
924
  assets = self.assets.filter(date=val_date)
923
925
  return assets
924
926
 
925
- def compute_lookthrough(self, sync_date: date, portfolio_total_asset_value: Decimal | None = None):
926
- if not self.primary_portfolio or not self.is_lookthrough:
927
- raise ValueError(
928
- "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
929
- )
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):
927
+ def compute_lookthrough(self, from_date: date, to_date: date | None = None):
935
928
  if not self.primary_portfolio or not self.is_lookthrough:
936
929
  raise ValueError(
937
930
  "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
938
931
  )
939
- positions = []
940
- for val_date in pd.date_range(from_date, to_date, freq="B").date:
932
+ positions = AssetPositionIterator(self)
933
+ if not to_date:
934
+ to_date = from_date
935
+ for from_date in pd.date_range(from_date, to_date, freq="B").date:
936
+ logger.info(f"Compute Look-Through for {self} at {from_date}")
941
937
  portfolio_total_asset_value = (
942
- self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
938
+ self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
943
939
  )
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)
940
+ positions.add(
941
+ list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
942
+ )
943
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
946
944
 
947
945
  def update_preferred_classification_per_instrument(self):
948
946
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -999,47 +997,50 @@ class Portfolio(DeleteToDisableMixin, WBModel):
999
997
  def get_representation_label_key(cls):
1000
998
  return "{{name}}"
1001
999
 
1002
- def bulk_create_positions(self, positions: list[AssetPosition], delete_leftovers: bool = False, **kwargs):
1000
+ def bulk_create_positions(
1001
+ self, positions: AssetPositionIterator, delete_leftovers: bool = False, force_save: bool = False, **kwargs
1002
+ ):
1003
1003
  if positions:
1004
- update_dates = set()
1005
- for position in positions:
1006
- position.portfolio = self
1007
- update_dates.add(position.date)
1008
-
1009
1004
  # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
1010
1005
  # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
1011
1006
  # 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)
1007
+
1008
+ dates = positions.get_dates()
1009
+ self.assets.filter(date__in=dates, is_estimated=True).delete()
1010
+
1011
+ if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
1012
+ leftover_positions_ids = list(
1013
+ self.assets.filter(date__in=dates).values_list("id", flat=True)
1014
+ ) # we need to get the ids otherwise the queryset is reevaluated later
1015
+ positions_list = list(positions)
1016
+ logger.info(
1017
+ f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
1018
+ )
1019
+ objs = AssetPosition.unannotated_objects.bulk_create(
1020
+ positions_list,
1021
+ update_fields=[
1022
+ "weighting",
1023
+ "initial_price",
1024
+ "initial_currency_fx_rate",
1025
+ "initial_shares",
1026
+ "currency_fx_rate_instrument_to_usd",
1027
+ "currency_fx_rate_portfolio_to_usd",
1028
+ "underlying_quote_price",
1029
+ "portfolio",
1030
+ "portfolio_created",
1031
+ "underlying_instrument",
1032
+ ],
1033
+ unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
1034
+ update_conflicts=True,
1035
+ batch_size=10000,
1036
+ )
1037
+ if delete_leftovers:
1038
+ objs_ids = list(map(lambda x: x.id, objs))
1039
+ leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
1040
+ logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
1041
+ AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
1042
+ for update_date, changed_weights in positions.get_weights().items():
1043
+ self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
1043
1044
 
1044
1045
  @classmethod
1045
1046
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
@@ -1053,54 +1054,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1053
1054
  def check_share_diff(self, val_date: date) -> bool:
1054
1055
  return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
1055
1056
 
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
1057
  @classmethod
1105
1058
  def get_contribution_df(cls, data, need_normalize: bool = False):
1106
1059
  df = pd.DataFrame(
@@ -1241,20 +1194,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1241
1194
  return portfolio
1242
1195
 
1243
1196
 
1244
- def default_estimate_net_value(val_date: date, instrument: Instrument) -> float | None:
1245
- portfolio = instrument.portfolio
1197
+ def default_estimate_net_value(
1198
+ val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
1199
+ ) -> float | None:
1200
+ portfolio: Portfolio = instrument.portfolio
1246
1201
  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
- ):
1202
+ if not weights:
1203
+ weights = portfolio.get_weights(previous_val_date)
1204
+ # 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
1205
+ if weights and (last_price := instrument.get_latest_price(previous_val_date)):
1254
1206
  with suppress(
1255
1207
  IndexError, InvalidAnalyticPortfolio
1256
1208
  ): # we silent any indexerror introduced by no returns for the past days
1257
- analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_val_date)
1209
+ analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
1258
1210
  return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1259
1211
 
1260
1212
 
@@ -1268,9 +1220,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
1268
1220
  ):
1269
1221
  with suppress(AssetPosition.DoesNotExist):
1270
1222
  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
- )
1223
+ compute_lookthrough_as_task.delay(instance.portfolio.id, earliest_primary_position_date, date.today())
1274
1224
 
1275
1225
 
1276
1226
  @shared_task(queue="portfolio")
@@ -1280,9 +1230,9 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
1280
1230
 
1281
1231
 
1282
1232
  @shared_task(queue="portfolio")
1283
- def batch_recompute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
1233
+ def compute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
1284
1234
  portfolio = Portfolio.objects.get(id=portfolio_id)
1285
- portfolio.batch_recompute_lookthrough(start, end)
1235
+ portfolio.compute_lookthrough(start, to_date=end)
1286
1236
 
1287
1237
 
1288
1238
  @receiver(investable_universe_updated, sender="wbfdm.Instrument")