wbportfolio 1.46.9__py2.py3-none-any.whl → 1.46.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,6 +1,6 @@
1
1
  from contextlib import suppress
2
2
  from datetime import date
3
- from decimal import Decimal
3
+ from decimal import Decimal, InvalidOperation
4
4
 
5
5
  from django.contrib import admin
6
6
  from django.db import models
@@ -393,7 +393,9 @@ class AssetPosition(ImportMixin, models.Model):
393
393
  analytical_objects = AnalyticalAssetPositionManager()
394
394
  unannotated_objects = models.Manager()
395
395
 
396
- def pre_save(self, create_underlying_quote_price_if_missing: bool = False):
396
+ def pre_save(
397
+ self, create_underlying_quote_price_if_missing: bool = False, infer_underlying_quote_price: bool = True
398
+ ):
397
399
  if not self.asset_valuation_date:
398
400
  self.asset_valuation_date = self.date
399
401
 
@@ -417,10 +419,10 @@ class AssetPosition(ImportMixin, models.Model):
417
419
 
418
420
  if not getattr(self, "currency", None):
419
421
  self.currency = self.underlying_quote.currency
420
- if not self.underlying_quote_price:
422
+ if not self.underlying_quote_price and infer_underlying_quote_price:
421
423
  try:
422
424
  # We get only the instrument price (and don't create it) because we don't want to create product instrument price on asset position propagation
423
- # Instead, we deciced to opt for a post_save based system that will assign the missing position price when a price is created
425
+ # Instead, we decided to opt for a post_save based system that will assign the missing position price when a price is created
424
426
  self.underlying_quote_price = InstrumentPrice.objects.get(
425
427
  calculated=False, instrument=self.underlying_quote, date=self.asset_valuation_date
426
428
  )
@@ -468,9 +470,12 @@ class AssetPosition(ImportMixin, models.Model):
468
470
  if self.initial_currency_fx_rate is None:
469
471
  self.initial_currency_fx_rate = Decimal(1.0)
470
472
  if self.currency_fx_rate_portfolio_to_usd and self.currency_fx_rate_instrument_to_usd:
471
- self.initial_currency_fx_rate = (
472
- self.currency_fx_rate_portfolio_to_usd.value / self.currency_fx_rate_instrument_to_usd.value
473
- )
473
+ try:
474
+ self.initial_currency_fx_rate = (
475
+ self.currency_fx_rate_portfolio_to_usd.value / self.currency_fx_rate_instrument_to_usd.value
476
+ )
477
+ except InvalidOperation:
478
+ self.initial_currency_fx_rate = Decimal(0.0)
474
479
 
475
480
  def save(self, *args, create_underlying_quote_price_if_missing: bool = False, **kwargs):
476
481
  self.pre_save(create_underlying_quote_price_if_missing=create_underlying_quote_price_if_missing)
@@ -65,7 +65,9 @@ class PMSInstrument(Instrument):
65
65
  try:
66
66
  return InstrumentPrice.objects.filter_only_valid_prices().get(instrument=self, date=val_date)
67
67
  except InstrumentPrice.DoesNotExist:
68
- if not self.inception_date or not self.prices.filter(date__lte=val_date).exists():
68
+ if (not self.inception_date or self.inception_date == val_date) and not self.prices.filter(
69
+ date__lte=val_date
70
+ ).exists():
69
71
  return InstrumentPrice.objects.get_or_create(
70
72
  instrument=self, date=val_date, defaults={"calculated": False, "net_value": self.issue_price}
71
73
  )[0]
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from collections import defaultdict
2
3
  from contextlib import suppress
3
4
  from datetime import date, timedelta
4
5
  from decimal import Decimal
@@ -26,6 +27,7 @@ from django.db.models import (
26
27
  from django.db.models.signals import post_save
27
28
  from django.dispatch import receiver
28
29
  from django.utils import timezone
30
+ from django.utils.functional import cached_property
29
31
  from pandas._libs.tslibs.offsets import BDay
30
32
  from psycopg.types.range import DateRange
31
33
  from skfolio.preprocessing import prices_to_returns
@@ -54,6 +56,63 @@ from . import ProductGroup
54
56
  logger = logging.getLogger("pms")
55
57
 
56
58
 
59
+ class PositionDictConverter:
60
+ """Utility class to load a dictionary of dictionary (dictionary of instrument_id and float per date) into a iterable of assetposition"""
61
+
62
+ def __init__(self, portfolio, prices: pd.DataFrame, infer_underlying_quote_price=False):
63
+ self.portfolio = portfolio
64
+ self.instruments = {}
65
+ self.fx_rates = defaultdict(dict)
66
+ self.prices = prices
67
+ self.infer_underlying_quote_price = infer_underlying_quote_price
68
+
69
+ def _get_instrument(self, instrument_id: int) -> Instrument:
70
+ try:
71
+ return self.instruments[instrument_id]
72
+ except KeyError:
73
+ instrument = Instrument.objects.get(id=instrument_id)
74
+ self.instruments[instrument_id] = instrument
75
+ return instrument
76
+
77
+ def _get_fx_rate(self, val_date: date, currency) -> CurrencyFXRates:
78
+ try:
79
+ return self.fx_rates[currency][val_date]
80
+ except KeyError:
81
+ fx_rate = CurrencyFXRates.objects.get_or_create(
82
+ currency=currency, date=val_date, defaults={"value": Decimal(0)}
83
+ )[0] # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
84
+ self.fx_rates[currency][val_date] = fx_rate
85
+ return fx_rate
86
+
87
+ def dict_to_model(self, val_date: date, instrument_id: int, weighting: float) -> AssetPosition:
88
+ underlying_quote = self._get_instrument(instrument_id)
89
+ currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
90
+ currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
91
+ position = AssetPosition(
92
+ underlying_quote=underlying_quote,
93
+ weighting=weighting,
94
+ date=val_date,
95
+ asset_valuation_date=val_date,
96
+ is_estimated=True,
97
+ portfolio=self.portfolio,
98
+ currency=underlying_quote.currency,
99
+ initial_price=self.prices.loc[pd.Timestamp(val_date), instrument_id],
100
+ currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
101
+ currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
102
+ initial_currency_fx_rate=None,
103
+ underlying_quote_price=None,
104
+ underlying_instrument=None,
105
+ )
106
+ position.pre_save(
107
+ infer_underlying_quote_price=self.infer_underlying_quote_price
108
+ ) # 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
109
+ return position
110
+
111
+ def convert(self, positions: dict[date, dict[int, float]]) -> Iterable[AssetPosition]:
112
+ for val_date, weights in positions.items():
113
+ yield from map(lambda row: self.dict_to_model(val_date, row[0], row[1]), weights.items())
114
+
115
+
57
116
  class DefaultPortfolioQueryset(QuerySet):
58
117
  def filter_invested_at_date(self, val_date: date) -> QuerySet:
59
118
  """
@@ -253,11 +312,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
253
312
  def imported_assets(self):
254
313
  return self.assets.filter(is_estimated=False)
255
314
 
256
- @property
315
+ @cached_property
257
316
  def pms_instruments(self):
258
- yield from Product.objects.filter(portfolios=self)
259
- yield from ProductGroup.objects.filter(portfolios=self)
260
- yield from Index.objects.filter(portfolios=self)
317
+ instruments = [i for i in Product.objects.filter(portfolios=self)]
318
+ instruments.extend([i for i in ProductGroup.objects.filter(portfolios=self)])
319
+ instruments.extend([i for i in Index.objects.filter(portfolios=self)])
320
+ return instruments
261
321
 
262
322
  @property
263
323
  def can_be_rebalanced(self):
@@ -298,31 +358,28 @@ class Portfolio(DeleteToDisableMixin, WBModel):
298
358
  )
299
359
  )
300
360
 
301
- def get_analytic_portfolio(self, val_date: date, with_previous_weights: bool = False) -> AnalyticPortfolio:
361
+ def get_analytic_portfolio(self, val_date: date, **kwargs) -> tuple[AnalyticPortfolio, pd.DataFrame]:
302
362
  """
303
363
  Return the analytic portfolio associated with this portfolio at the given date
304
364
 
305
365
  the analytic portfolio inherit from SKFolio Portfolio and can be used to access all this library methods
306
366
  Args:
307
367
  val_date: the date to calculate the portfolio for
308
- with_previous_weights: If true, excludes the previous weights into the analytic portfolio (might be necessary for some metrics)
309
368
 
310
369
  Returns:
311
370
  The instantiated analytic portfolio
312
371
  """
313
372
  weights = self.get_weights(val_date)
314
373
  instrument_ids = weights.keys()
315
- previous_weights = None
316
- if with_previous_weights:
317
- if previous_date := self.get_latest_asset_position_date(val_date - timedelta(days=1)):
318
- previous_weights = self.get_weights(previous_date)
319
- instrument_ids = previous_weights.keys()
320
- returns = self.get_returns(instrument_ids, (val_date - BDay(3)).date(), val_date)[0]
374
+ return_date = (val_date + BDay(1)).date()
375
+ returns, prices = self.get_returns(instrument_ids, (val_date - BDay(2)).date(), return_date, **kwargs)
321
376
  returns = returns.fillna(0) # not sure this is what we want
322
- return AnalyticPortfolio(
323
- X=returns,
324
- weights=weights,
325
- previous_weights=previous_weights,
377
+ return (
378
+ AnalyticPortfolio(
379
+ X=returns,
380
+ weights=weights,
381
+ ),
382
+ prices,
326
383
  )
327
384
 
328
385
  def is_invested_at_date(self, val_date: date) -> bool:
@@ -567,10 +624,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
567
624
 
568
625
  def get_child_portfolios(self, val_date: date) -> set["Portfolio"]:
569
626
  child_portfolios = set()
570
- if pms_instruments := list(self.pms_instruments):
627
+ instrument_rel = InstrumentPortfolioThroughModel.objects.filter(portfolio=self)
628
+ if instrument_rel.exists():
571
629
  for parent_portfolio in Portfolio.objects.filter(
572
630
  id__in=AssetPosition.unannotated_objects.filter(
573
- date=val_date, underlying_quote__in=pms_instruments
631
+ date=val_date, underlying_quote__in=instrument_rel.values("instrument")
574
632
  ).values("portfolio")
575
633
  ):
576
634
  child_portfolios.add(parent_portfolio)
@@ -592,24 +650,29 @@ class Portfolio(DeleteToDisableMixin, WBModel):
592
650
  evaluate_rebalancer: bool = True,
593
651
  ):
594
652
  logger.info(f"change at date for {self} at {val_date}")
595
- qs = self.assets.filter(date=val_date).filter(
596
- Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
597
- )
598
653
 
599
- # We normalize weight across the portfolio for a given date
600
- if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
601
- total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
602
- # We check if this actually necessary
603
- # (i.e. if the weight is already summed to 100%, it is already normalized)
604
- if not total_weighting or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001) or recompute_weighting:
605
- total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
606
- # TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
607
- for asset in qs:
608
- if total_value:
609
- asset.weighting = asset._total_value_fx_portfolio / total_value
610
- elif total_weighting:
611
- asset.weighting = asset.weighting / total_weighting
612
- asset.save()
654
+ if recompute_weighting:
655
+ # We normalize weight across the portfolio for a given date
656
+ qs = self.assets.filter(date=val_date).filter(
657
+ Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
658
+ )
659
+ if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
660
+ total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
661
+ # We check if this actually necessary
662
+ # (i.e. if the weight is already summed to 100%, it is already normalized)
663
+ if (
664
+ not total_weighting
665
+ or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001)
666
+ or force_recompute_weighting
667
+ ):
668
+ total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
669
+ # TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
670
+ for asset in qs:
671
+ if total_value:
672
+ asset.weighting = asset._total_value_fx_portfolio / total_value
673
+ elif total_weighting:
674
+ asset.weighting = asset.weighting / total_weighting
675
+ asset.save()
613
676
 
614
677
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
615
678
  self.estimate_net_asset_values(val_date)
@@ -641,17 +704,21 @@ class Portfolio(DeleteToDisableMixin, WBModel):
641
704
  dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
642
705
 
643
706
  def evaluate_rebalancing(self, val_date: date):
644
- # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a trade proposal automatically
645
- next_business_date = (val_date + BDay(1)).date()
646
-
647
707
  if hasattr(self, "automatic_rebalancer"):
648
- logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
708
+ # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a trade proposal automatically
709
+ next_business_date = (val_date + BDay(1)).date()
710
+ if self.automatic_rebalancer.is_valid(val_date): # we evaluate the rebalancer in t0 and t+1
711
+ logger.info(f"Evaluate Rebalancing for {self} at {val_date}")
712
+ self.automatic_rebalancer.evaluate_rebalancing(val_date)
649
713
  if self.automatic_rebalancer.is_valid(next_business_date):
714
+ logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
650
715
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
651
716
 
652
717
  def estimate_net_asset_values(self, val_date: date):
653
718
  for instrument in self.pms_instruments:
654
- if net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path:
719
+ if instrument.is_active_at_date(val_date) and (
720
+ net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
721
+ ):
655
722
  logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
656
723
  net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
657
724
  estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument)
@@ -671,76 +738,32 @@ class Portfolio(DeleteToDisableMixin, WBModel):
671
738
  instrument.update_last_valuation_date()
672
739
  instrument.update_last_valuation_date()
673
740
 
674
- def get_estimated_portfolio_from_weights(
675
- self, val_date: date, weights: dict[int, float], prices: dict[int, Decimal] | None = None
676
- ) -> Iterable[AssetPosition]:
677
- """
678
- Given weights and the corresponding instrument price, instantiate asset positions (as AssetPosition object)
679
-
680
- Args:
681
- val_date: The positions valuation date
682
- weights: The positions weights as dictionary (Instrument IDS as key and weights as values)
683
- prices: The prices associated with each position
684
-
685
- Returns:
686
- Yield AssetPosition objects.
687
- """
688
- if prices is None:
689
- prices = dict()
690
- try:
691
- currency_fx_rate_portfolio_to_usd = CurrencyFXRates.objects.get(date=val_date, currency=self.currency)
692
- except CurrencyFXRates.DoesNotExist:
693
- currency_fx_rate_portfolio_to_usd = None
694
- for underlying_quote_id, next_weight in weights.items():
695
- underlying_quote = Instrument.objects.get(id=underlying_quote_id)
696
- position = AssetPosition(
697
- underlying_quote=underlying_quote,
698
- weighting=next_weight,
699
- date=val_date,
700
- asset_valuation_date=val_date,
701
- is_estimated=True,
702
- portfolio=self,
703
- currency=underlying_quote.currency,
704
- currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
705
- initial_price=prices.get(underlying_quote_id, None),
706
- initial_currency_fx_rate=None,
707
- currency_fx_rate_instrument_to_usd=None,
708
- underlying_quote_price=None,
709
- underlying_instrument=None,
710
- )
711
- position.pre_save()
712
- yield position
713
-
714
741
  def batch_portfolio(self, start_date: date, end_date: date):
715
742
  """
716
743
  Create the cumulative portfolios between the two given dates and stop at the first rebalancing (if any)
717
744
 
718
745
  Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
719
746
  """
720
- analytic_portfolio = self.get_analytic_portfolio(start_date)
721
747
  rebalancer = getattr(self, "automatic_rebalancer", None)
722
- initial_assets = analytic_portfolio.assets
723
- positions = []
748
+
749
+ weights = self.get_weights(start_date) # initial weights
750
+ positions = dict()
724
751
  next_trade_proposal = None
725
752
  rebalancing_date = None
726
753
  logger.info(f"compute next weights in batch for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
727
- returns, prices = self.get_returns(initial_assets, (start_date - BDay(3)).date(), end_date, ffill_returns=True)
754
+ returns, prices = self.get_returns(weights.keys(), (start_date - BDay(3)).date(), end_date, ffill_returns=True)
728
755
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
729
756
  to_date = to_date_ts.date()
730
757
  logger.info(f"Processing {to_date:%Y-%m-%d}")
731
758
  if rebalancer and rebalancer.is_valid(to_date):
732
759
  rebalancing_date = to_date
733
760
  break
734
- # with suppress(IndexError):
735
761
  last_returns = returns.loc[[to_date_ts], :]
736
- next_weights = analytic_portfolio.get_next_weights(last_returns.iloc[-1, :].T)
737
- positions.extend(
738
- self.get_estimated_portfolio_from_weights(
739
- to_date, next_weights, prices.loc[to_date_ts, :].dropna().to_dict()
740
- )
741
- )
742
- analytic_portfolio = AnalyticPortfolio(X=last_returns, weights=next_weights)
762
+ analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
763
+ positions[to_date] = analytic_portfolio.get_next_weights()
743
764
 
765
+ positions_generator = PositionDictConverter(self, prices)
766
+ positions = list(positions_generator.convert(positions))
744
767
  self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False)
745
768
  if rebalancing_date:
746
769
  next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
@@ -774,18 +797,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
774
797
  self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
775
798
  ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
776
799
  logger.info(f"computing next weight for {self} from {from_date:%Y-%m-%d} to {to_date:%Y-%m-%d}")
777
- analytic_portfolio = self.get_analytic_portfolio(from_date)
778
- returns, prices = self.get_returns(analytic_portfolio.assets, (from_date - BDay(3)).date(), to_date)
779
- if not returns.empty:
780
- weights = analytic_portfolio.get_next_weights(returns.iloc[-1, :].T)
781
- positions = list(
782
- map(
783
- lambda a: _parse_position(a),
784
- self.get_estimated_portfolio_from_weights(
785
- to_date, weights, prices.iloc[-1, :].T.dropna().to_dict()
786
- ),
787
- )
788
- )
800
+ analytic_portfolio, to_prices = self.get_analytic_portfolio(from_date)
801
+ if not to_prices.empty:
802
+ weights = analytic_portfolio.get_next_weights()
803
+ positions_generator = PositionDictConverter(
804
+ self, to_prices, infer_underlying_quote_price=True
805
+ ).convert({to_date: weights})
806
+ positions = list(map(lambda a: _parse_position(a), positions_generator))
789
807
  self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
790
808
 
791
809
  def get_lookthrough_positions(
@@ -932,8 +950,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
932
950
  for position in positions:
933
951
  position.portfolio = self
934
952
  update_dates.add(position.date)
935
- self.assets.filter(date__in=update_dates, is_estimated=True).delete()
936
- leftover_positions = self.assets.filter(date__in=update_dates).all()
953
+ # self.assets.filter(date__in=update_dates, is_estimated=True).delete()
954
+ leftover_positions_ids = list(
955
+ self.assets.filter(date__in=update_dates).values_list("id", flat=True)
956
+ ) # we need to get the ids otherwise the queryset is reevaluated later
957
+ logger.info(f"bulk saving {len(positions)} positions ({len(leftover_positions_ids)} leftovers) ...")
937
958
  objs = AssetPosition.unannotated_objects.bulk_create(
938
959
  positions,
939
960
  update_fields=[
@@ -951,11 +972,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
951
972
  ],
952
973
  unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
953
974
  update_conflicts=True,
975
+ batch_size=10000,
954
976
  )
955
977
  if delete_leftovers:
956
- for leftover_position in leftover_positions:
957
- if leftover_position not in objs: # this works because __eq__ of a django model use the id field
958
- leftover_position.delete()
978
+ objs_ids = list(map(lambda x: x.id, objs))
979
+ leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
980
+ logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
981
+ AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
959
982
  for update_date in sorted(update_dates):
960
983
  self.change_at_date(update_date, **kwargs)
961
984
 
@@ -972,7 +995,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
972
995
  return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
973
996
 
974
997
  def get_returns(
975
- self, instruments: Iterable, from_date: date, to_date: date, ffill_returns: bool = True
998
+ self,
999
+ instruments: Iterable,
1000
+ from_date: date,
1001
+ to_date: date,
1002
+ ffill_returns: bool = True,
1003
+ convert_to_portfolio_currency: bool = True,
976
1004
  ) -> tuple[pd.DataFrame, pd.DataFrame]:
977
1005
  """
978
1006
  Utility methods to get instrument returns for a given date range
@@ -988,8 +1016,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
988
1016
  prices = InstrumentPrice.objects.filter(
989
1017
  instrument__in=instruments, date__gte=from_date, date__lte=to_date
990
1018
  ).annotate(
991
- # fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", self.currency),
992
- price_fx_portfolio=F("net_value") # * F("net_value")
1019
+ fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies(
1020
+ "date", "instrument__currency", self.currency
1021
+ ),
1022
+ price_fx_portfolio=F("net_value") * F("fx_rate") if convert_to_portfolio_currency else F("net_value"),
993
1023
  )
994
1024
  prices_df = (
995
1025
  pd.DataFrame(
@@ -1190,9 +1220,9 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
1190
1220
  if (
1191
1221
  previous_date := portfolio.get_latest_asset_position_date(val_date - BDay(1), with_estimated=True)
1192
1222
  ) and portfolio.assets.filter(date=val_date).exists():
1193
- analytic_portfolio = portfolio.get_analytic_portfolio(val_date, with_previous_weights=True)
1194
1223
  with suppress(IndexError):
1195
1224
  if last_price := instrument.get_latest_price(previous_date):
1225
+ analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_date)
1196
1226
  return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1197
1227
 
1198
1228
 
@@ -99,11 +99,16 @@ class Rebalancer(ComplexToStringMixin, models.Model):
99
99
  return pivot_date
100
100
 
101
101
  def is_valid(self, trade_date: date) -> bool:
102
- for valid_datetime in self.get_rrule(trade_date):
103
- valid_date = self._get_next_valid_date(valid_datetime.date())
104
- if valid_date == trade_date:
102
+ if TradeProposal.objects.filter(
103
+ portfolio=self.portfolio, status=TradeProposal.Status.APPROVED, trade_date=trade_date
104
+ ).exists(): # if a already approved trade proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")
105
+ return False
106
+ for initial_valid_datetime in self.get_rrule(trade_date):
107
+ initial_valid_date = initial_valid_datetime.date()
108
+ alternative_valid_date = self._get_next_valid_date(initial_valid_date)
109
+ if trade_date in [alternative_valid_date, initial_valid_date]:
105
110
  return True
106
- if valid_date > trade_date:
111
+ if alternative_valid_date > trade_date:
107
112
  break
108
113
  return False
109
114
 
@@ -79,6 +79,8 @@ class TradeProposal(RiskCheckMixin, WBModel):
79
79
  if not self.rebalancing_model and (rebalancer := getattr(self.portfolio, "automatic_rebalancer", None)):
80
80
  self.rebalancing_model = rebalancer.rebalancing_model
81
81
  super().save(*args, **kwargs)
82
+ if self.status == TradeProposal.Status.APPROVED:
83
+ self.portfolio.change_at_date(self.trade_date)
82
84
 
83
85
  def _get_checked_object_field_name(self) -> str:
84
86
  """
@@ -234,7 +236,6 @@ class TradeProposal(RiskCheckMixin, WBModel):
234
236
  )
235
237
  service.normalize()
236
238
  service.is_valid()
237
-
238
239
  for trade_dto in service.validated_trades:
239
240
  instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
240
241
  currency_fx_rate = instrument.currency.convert(
@@ -378,8 +379,6 @@ class TradeProposal(RiskCheckMixin, WBModel):
378
379
  for trade in self.trades.all():
379
380
  trade.execute()
380
381
  trade.save()
381
- self.portfolio.change_at_date(self.trade_date)
382
- # replay_as_task.delay(self.id)
383
382
 
384
383
  def can_approve(self):
385
384
  errors = dict()
@@ -16,16 +16,14 @@ class Portfolio(BasePortfolio):
16
16
  )
17
17
  return df
18
18
 
19
- def get_next_weights(self, returns: pd.Series) -> dict[int, float]:
19
+ def get_next_weights(self) -> dict[int, float]:
20
20
  """
21
- Given the next returns, compute the next weights of this portfolio
22
-
23
- Args:
24
- returns: The returns for the next day as a pandas series
21
+ Given the next returns, compute the drifted weights of this portfolio
25
22
 
26
23
  Returns:
27
24
  A dictionary of weights (instrument ids as keys and weights as values)
28
25
  """
26
+ returns = self.X.iloc[-1, :].T
29
27
  weights = self.all_weights_per_observation.iloc[-1, :].T
30
28
  if weights.sum() != 0:
31
29
  weights /= weights.sum()
@@ -35,7 +33,5 @@ class Portfolio(BasePortfolio):
35
33
  return contribution.dropna().to_dict()
36
34
 
37
35
  def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
38
- if self.previous_weights is None:
39
- raise ValueError("No previous weights available")
40
- expected_returns = self.previous_weights @ self.X.iloc[-1, :].T
36
+ expected_returns = self.weights @ self.X.iloc[-1, :].T
41
37
  return previous_net_asset_value * (1.0 + expected_returns)
@@ -97,7 +97,7 @@ class TradingService:
97
97
  effective_shares = 0
98
98
  instrument_type = currency = None
99
99
  if effective_pos := effective_portfolio.positions_map.get(instrument, None):
100
- effective_weight = target_weight = effective_pos.weighting
100
+ effective_weight = effective_pos.weighting
101
101
  effective_shares = effective_pos.shares
102
102
  instrument_type, currency = effective_pos.instrument_type, effective_pos.currency
103
103
  if target_pos := target_portfolio.positions_map.get(instrument, None):
@@ -1,5 +1,6 @@
1
1
  from decimal import Decimal
2
2
 
3
+ import numpy as np
3
4
  import pandas as pd
4
5
  from django.db.models import Q, QuerySet
5
6
  from wbfdm.enums import MarketData
@@ -26,7 +27,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
26
27
  self.market_cap_df = pd.DataFrame(
27
28
  instruments.dl.market_data(
28
29
  values=[MarketData.MARKET_CAPITALIZATION],
29
- exact_date=self.last_effective_date,
30
+ exact_date=self.trade_date,
30
31
  target_currency=self.TARGET_CURRENCY,
31
32
  )
32
33
  )
@@ -80,9 +81,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
80
81
 
81
82
  if not instrument_ids:
82
83
  instrument_ids = list(
83
- self.portfolio.assets.filter(date=self.last_effective_date).values_list(
84
- "underlying_instrument", flat=True
85
- )
84
+ self.portfolio.assets.filter(date=self.trade_date).values_list("underlying_instrument", flat=True)
86
85
  )
87
86
 
88
87
  return Instrument.objects.filter(id__in=instrument_ids).filter(
@@ -107,14 +106,16 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
107
106
 
108
107
  def get_target_portfolio(self) -> Portfolio:
109
108
  positions = []
110
- market_cap_df = self.market_cap_df.dropna()
111
- total_market_cap = market_cap_df.sum()
112
-
113
- for underlying_instrument, market_cap in market_cap_df.to_dict().items():
109
+ total_market_cap = self.market_cap_df.dropna().sum()
110
+ for underlying_instrument, market_cap in self.market_cap_df.to_dict().items():
111
+ if np.isnan(market_cap):
112
+ weighting = Decimal(0)
113
+ else:
114
+ weighting = Decimal(market_cap / total_market_cap)
114
115
  positions.append(
115
116
  Position(
116
117
  underlying_instrument=underlying_instrument,
117
- weighting=Decimal(market_cap / total_market_cap),
118
+ weighting=weighting,
118
119
  date=self.trade_date,
119
120
  )
120
121
  )
@@ -21,7 +21,7 @@ from wbportfolio.models import (
21
21
  Trade,
22
22
  )
23
23
 
24
- from ...models.portfolio import update_portfolio_after_investable_universe
24
+ from ...models.portfolio import PositionDictConverter, update_portfolio_after_investable_universe
25
25
  from .utils import PortfolioTestMixin
26
26
 
27
27
  fake = Faker()
@@ -259,7 +259,7 @@ class TestPortfolioModel(PortfolioTestMixin):
259
259
  ):
260
260
  asset_position_factory.create_batch(10, portfolio=portfolio, date=weekday)
261
261
 
262
- portfolio.change_at_date(weekday, compute_metrics=True)
262
+ portfolio.change_at_date(weekday, compute_metrics=True, recompute_weighting=True)
263
263
 
264
264
  # test that change at date normalize the weighting
265
265
  total_value = AssetPosition.objects.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
@@ -905,10 +905,10 @@ class TestPortfolioModel(PortfolioTestMixin):
905
905
  ):
906
906
  i1 = instrument_factory.create()
907
907
  i2 = instrument_factory.create()
908
- p10 = instrument_price_factory.create(instrument=i1, date=(weekday - BDay(1)).date())
909
- p11 = instrument_price_factory.create(instrument=i1, date=weekday)
910
- p20 = instrument_price_factory.create(instrument=i2, date=(weekday - BDay(1)).date())
911
- p21 = instrument_price_factory.create(instrument=i2, date=weekday)
908
+ p10 = instrument_price_factory.create(instrument=i1, date=weekday)
909
+ p11 = instrument_price_factory.create(instrument=i1, date=(weekday + BDay(1)).date())
910
+ p20 = instrument_price_factory.create(instrument=i2, date=weekday)
911
+ p21 = instrument_price_factory.create(instrument=i2, date=(weekday + BDay(1)).date())
912
912
 
913
913
  a1 = asset_position_factory.create(date=weekday, portfolio=portfolio, underlying_instrument=i1)
914
914
  a1.refresh_from_db()
@@ -917,12 +917,12 @@ class TestPortfolioModel(PortfolioTestMixin):
917
917
  )
918
918
  a2.refresh_from_db()
919
919
 
920
- analytic_portfolio = portfolio.get_analytic_portfolio(weekday)
920
+ analytic_portfolio, _ = portfolio.get_analytic_portfolio(weekday, convert_to_portfolio_currency=False)
921
921
  assert analytic_portfolio.weights.tolist() == [float(a1.weighting), float(a2.weighting)]
922
922
  expected_X = pd.DataFrame(
923
923
  [[float(p11.net_value / p10.net_value - Decimal(1)), float(p21.net_value / p20.net_value - Decimal(1))]],
924
924
  columns=[i1.id, i2.id],
925
- index=[weekday],
925
+ index=[(weekday + BDay(1)).date()],
926
926
  )
927
927
  expected_X.index = pd.to_datetime(expected_X.index)
928
928
  pd.testing.assert_frame_equal(analytic_portfolio.X, expected_X, check_names=False, check_freq=False)
@@ -997,14 +997,15 @@ class TestPortfolioModel(PortfolioTestMixin):
997
997
  fx_instrument = currency_fx_rates_factory.create(currency=instrument.currency, date=weekday)
998
998
 
999
999
  weights = {instrument.id: random.random()}
1000
- prices = {}
1001
-
1002
- res = list(portfolio.get_estimated_portfolio_from_weights(weekday, weights, prices))
1000
+ prices = pd.DataFrame([{instrument.id: p.net_value, "date": weekday}]).set_index("date")
1001
+ prices.index = pd.to_datetime(prices.index)
1002
+ converter = PositionDictConverter(portfolio, prices)
1003
+ res = list(converter.convert({weekday: weights}))
1003
1004
  a = res[0]
1004
1005
  assert len(res) == 1
1005
1006
  assert a.date == weekday
1006
1007
  assert a.underlying_quote == instrument
1007
- assert a.underlying_quote_price == p
1008
+ assert a.underlying_quote_price == None
1008
1009
  assert a.initial_price == p.net_value
1009
1010
  assert a.weighting == weights[instrument.id]
1010
1011
  assert a.currency_fx_rate_portfolio_to_usd == fx_portfolio
@@ -1129,7 +1130,9 @@ class TestPortfolioModel(PortfolioTestMixin):
1129
1130
  i11.refresh_from_db()
1130
1131
  i12.refresh_from_db()
1131
1132
  i13.refresh_from_db()
1132
- returns, _ = portfolio.get_returns([i1.id, i2.id], from_date=v1, to_date=v3)
1133
+ returns, _ = portfolio.get_returns(
1134
+ [i1.id, i2.id], from_date=v1, to_date=v3, convert_to_portfolio_currency=False
1135
+ )
1133
1136
 
1134
1137
  expected_returns = pd.DataFrame(
1135
1138
  [[i12.net_value / i11.net_value - 1], [i13.net_value / i12.net_value - 1]],
@@ -13,7 +13,7 @@ def test_get_next_weights():
13
13
  weights = [w0, w1, w2]
14
14
  returns = [r0, r1, r2]
15
15
  portfolio = Portfolio(X=pd.DataFrame([returns]), weights=pd.Series(weights))
16
- next_weights = portfolio.get_next_weights(pd.Series(returns))
16
+ next_weights = portfolio.get_next_weights()
17
17
 
18
18
  assert next_weights[0] == w0 * (r0 + 1) / (w0 * (r0 + 1) + w1 * (r1 + 1) + w2 * (r2 + 1))
19
19
  assert next_weights[1] == w1 * (r1 + 1) / (w0 * (r0 + 1) + w1 * (r1 + 1) + w2 * (r2 + 1))
@@ -29,7 +29,7 @@ def test_get_estimate_net_value():
29
29
  r2 = -0.23
30
30
  weights = [w0, w1, w2]
31
31
  returns = [r0, r1, r2]
32
- portfolio = Portfolio(X=pd.DataFrame([returns]), weights=pd.Series(weights), previous_weights=pd.Series(weights))
32
+ portfolio = Portfolio(X=pd.DataFrame([returns]), weights=pd.Series(weights))
33
33
  current_price = 100
34
34
  net_asset_value = portfolio.get_estimate_net_value(current_price)
35
35
  return net_asset_value == current_price * (1.0 + w0 * r0 + w1 * r1 + w2 * r2)
@@ -138,8 +138,8 @@ class TestMarketCapitalizationRebalancing:
138
138
 
139
139
  i1 = instrument_factory()
140
140
  i2 = instrument_factory()
141
- instrument_price_factory.create(instrument=i1, date=last_effective_date)
142
- instrument_price_factory.create(instrument=i2, date=last_effective_date)
141
+ instrument_price_factory.create(instrument=i1, date=weekday)
142
+ instrument_price_factory.create(instrument=i2, date=weekday)
143
143
  return MarketCapitalizationRebalancing(portfolio, weekday, last_effective_date, instrument_ids=[i1.id, i2.id])
144
144
 
145
145
  def test_is_valid(self, portfolio, weekday, model, instrument_factory, instrument_price_factory):
@@ -151,8 +151,8 @@ class TestMarketCapitalizationRebalancing:
151
151
  def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
152
152
  i1 = model.market_cap_df.index[0]
153
153
  i2 = model.market_cap_df.index[1]
154
- mkt12 = InstrumentPrice.objects.get(instrument_id=i1, date=model.last_effective_date).market_capitalization
155
- mkt21 = InstrumentPrice.objects.get(instrument_id=i2, date=model.last_effective_date).market_capitalization
154
+ mkt12 = InstrumentPrice.objects.get(instrument_id=i1, date=weekday).market_capitalization
155
+ mkt21 = InstrumentPrice.objects.get(instrument_id=i2, date=weekday).market_capitalization
156
156
 
157
157
  target_portfolio = model.get_target_portfolio()
158
158
  target_positions = target_portfolio.positions_map
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbportfolio
3
- Version: 1.46.9
3
+ Version: 1.46.10
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  License-File: LICENSE
6
6
  Requires-Dist: cryptography==3.4.*
@@ -243,10 +243,10 @@ wbportfolio/migrations/0074_alter_rebalancer_frequency_and_more.py,sha256=o01rBj
243
243
  wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
244
244
  wbportfolio/models/__init__.py,sha256=PDLJry5w1zE4N4arQh20_uFi2v7gy9QyavJ_rfGE21Q,882
245
245
  wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
246
- wbportfolio/models/asset.py,sha256=V-fSAF1bsfj_Td3VfdKyhCgcwB_xPS6vTI90Dh5iDx8,37502
246
+ wbportfolio/models/asset.py,sha256=TEOsSlbog6Yw0PZiMxjnCccp6ApXAAJNd3BIx_wU7D8,37749
247
247
  wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
248
248
  wbportfolio/models/indexes.py,sha256=iLYF2gzNzX4GLj_Nh3fybUcAQ1TslnT0wgQ6mN164QI,728
249
- wbportfolio/models/portfolio.py,sha256=r_qTDNTeUu9QiJ6g_g1rIp5yOuUOwinqadJtykb9KE8,55501
249
+ wbportfolio/models/portfolio.py,sha256=d4cWfv_mJpqvdYLYW-DFoT5wDHjCGf7UO_0QsM5NoME,57187
250
250
  wbportfolio/models/portfolio_cash_flow.py,sha256=2blPiXSw7dbhUVd-7LcxDBb4v0SheNOdvRK3MFYiChA,7273
251
251
  wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
252
252
  wbportfolio/models/portfolio_relationship.py,sha256=mMb18UMRWg9kx_9uIPkMktwORuXXLjKdgRPQQvB6fVE,5486
@@ -261,7 +261,7 @@ wbportfolio/models/graphs/portfolio.py,sha256=NwkehWvTcyTYrKO5ku3eNNaYLuBwuLdSbT
261
261
  wbportfolio/models/graphs/utils.py,sha256=1AMpEE9mDuUZ82XgN2irxjCW1-LmziROhKevEBo0mJE,2347
262
262
  wbportfolio/models/llm/wbcrm/analyze_relationship.py,sha256=_y2Myc-M2hXQDkRGXvzsM0ZNC31dmxSHHz5BKMtymww,2106
263
263
  wbportfolio/models/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
264
- wbportfolio/models/mixins/instruments.py,sha256=IucFwxbSxyqLwDbaMYg_vKh1BPwGqo1VERpj0U-nS0Q,6728
264
+ wbportfolio/models/mixins/instruments.py,sha256=SgBreTpa_X3uyCWo7t8B0VaTtl49IjmBMe4Pab6TjAM,6796
265
265
  wbportfolio/models/mixins/liquidity_stress_test.py,sha256=whkzjtbOyl_ncNyaQBORb_Z_rDgcvfdTYPgqPolu7dA,58865
266
266
  wbportfolio/models/reconciliations/__init__.py,sha256=MXH5fZIPGDRBgJkO6wVu_NLRs8fkP1im7G6d-h36lQY,127
267
267
  wbportfolio/models/reconciliations/account_reconciliation_lines.py,sha256=QP6M7hMcyFbuXBa55Y-azui6Dl_WgbzMntEqWzQkbfM,7394
@@ -272,24 +272,24 @@ wbportfolio/models/transactions/claim.py,sha256=agdpGqxpO0FSzYDWV-Gv1tQY46k0LN9C
272
272
  wbportfolio/models/transactions/dividends.py,sha256=naL5xeDQfUBf5KyGt7y-tTcHL22nzZumT8DV6AaG8Bg,1064
273
273
  wbportfolio/models/transactions/expiry.py,sha256=vnNHdcC1hf2HP4rAbmoGgOfagBYKNFytqOwzOI0MlVI,144
274
274
  wbportfolio/models/transactions/fees.py,sha256=ffvqo8I4A0l5rLi00jJ6sGot0jmnkoxaNsbDzdPLwCg,5712
275
- wbportfolio/models/transactions/rebalancing.py,sha256=vEYdYhog9jiPw_xQd_XQunNzcpHZ8Zk18kQhsN4HuhA,6596
276
- wbportfolio/models/transactions/trade_proposals.py,sha256=gsvgEI5yqmFb_bCN4FsgfyQqslMXLQlzTe3G7odwTCA,21366
275
+ wbportfolio/models/transactions/rebalancing.py,sha256=3p5r6m68_7AD783hYAlDZCnUz1TOLVa9mvF5zConTMo,7036
276
+ wbportfolio/models/transactions/trade_proposals.py,sha256=6U0BFGPrX9aOOam4xFbo8asbjzkUUh0SWMeedTXX4Lk,21386
277
277
  wbportfolio/models/transactions/trades.py,sha256=gbXvxiyh8bvg6ldyd8qBwRPv4t0Tf5cbZXBnZXPIomc,27861
278
278
  wbportfolio/models/transactions/transactions.py,sha256=4THsE4xqdigZAwWKYfTNRLPJlkmAmsgE70Ribp9Lnrk,7127
279
279
  wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
280
280
  wbportfolio/pms/typing.py,sha256=WKP5tYyYt7DbMo25VM99V4IAM9oDSIPZyR3yXCzeZEA,5920
281
281
  wbportfolio/pms/analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
282
- wbportfolio/pms/analytics/portfolio.py,sha256=93hcHEuBOPi534El2HVCIyjs9MBQMX7dIZ97JIpNV1c,1535
282
+ wbportfolio/pms/analytics/portfolio.py,sha256=vE0KA6Z037bUdmBTkYuBqXElt80nuYObNzY_kWvxEZY,1360
283
283
  wbportfolio/pms/statistics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
284
284
  wbportfolio/pms/trading/__init__.py,sha256=R_yLKc54sCak8A1cW0O1Aszrcv5KV8mC_3h17Hr20e4,36
285
- wbportfolio/pms/trading/handler.py,sha256=Xpgo719S0jE1wUTTyGFpYccPEIg9GXghWEAdYawJbrk,7165
285
+ wbportfolio/pms/trading/handler.py,sha256=9RDRQFcnk55t69KSFYZxZDFOvd942-J4St-zJ7e8zlY,7149
286
286
  wbportfolio/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
287
287
  wbportfolio/rebalancing/base.py,sha256=NwTGZtBm1f35gj5Jp6iTyyFvDT1GSIztN990cKBvYzQ,637
288
288
  wbportfolio/rebalancing/decorators.py,sha256=JhQ2vkcIuGBTGvNmpkQerdw2-vLq-RAb0KAPjOrETkw,515
289
289
  wbportfolio/rebalancing/models/__init__.py,sha256=AQjG7Tu5vlmhqncVoYOjpBKU2UIvgo9FuP2_jD2w-UI,232
290
290
  wbportfolio/rebalancing/models/composite.py,sha256=XAjJqLRNsV-MuBKrat3THEfAWs6PXQNSO0g8k8MtBXo,1157
291
291
  wbportfolio/rebalancing/models/equally_weighted.py,sha256=U29MOHJMQMIg7Y7W_8t5K3nXjaznzt4ArIxQSiv0Xok,863
292
- wbportfolio/rebalancing/models/market_capitalization_weighted.py,sha256=myb7j_aJns5dfuUXzL4cPSK8R8X540h0INiXB6z-aV4,4867
292
+ wbportfolio/rebalancing/models/market_capitalization_weighted.py,sha256=LvzV7a8fNbpsRahysvKbE6oNSQccWpnnne-rlDj3uzU,4928
293
293
  wbportfolio/rebalancing/models/model_portfolio.py,sha256=XQdvs03-0M9YUnL4DidwZC4E6k-ANCNcZ--T_aaOXTQ,1233
294
294
  wbportfolio/reports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
295
295
  wbportfolio/reports/monthly_position_report.py,sha256=e7BzjDd6eseUOwLwQJXKvWErQ58YnCsznHU2VtR6izM,2981
@@ -371,7 +371,7 @@ wbportfolio/tests/models/test_merge.py,sha256=sdsjiZsmR6vsUKwTa5kkvL6QTeAZqtd_EP
371
371
  wbportfolio/tests/models/test_portfolio_cash_flow.py,sha256=X8dsXexsb1b0lBiuGzu40ps_Az_1UmmKT0eo1vbXH94,5792
372
372
  wbportfolio/tests/models/test_portfolio_cash_targets.py,sha256=q8QWAwt-kKRkLC0E05GyRhF_TTQXIi8bdHjXVU0fCV0,965
373
373
  wbportfolio/tests/models/test_portfolio_swing_pricings.py,sha256=kr2AOcQkyg2pX3ULjU-o9ye-NVpjMrrfoe-DVbYCbjs,1656
374
- wbportfolio/tests/models/test_portfolios.py,sha256=YF1SRS-REJFQhbtxs1LfIUs91jmHpI7pXBE70pW4W7E,51735
374
+ wbportfolio/tests/models/test_portfolios.py,sha256=_LtxyTIZqQUB_sJ4DpdE1BNkoSssGeYPgjGaOYciW2E,52059
375
375
  wbportfolio/tests/models/test_product_groups.py,sha256=AcdxhurV-n_bBuUsfD1GqVtwLFcs7VI2CRrwzsIUWbU,3337
376
376
  wbportfolio/tests/models/test_products.py,sha256=5YYmQreFnaKLbWmrSib103wgLalqn8u01Fnh3A0XMz8,8217
377
377
  wbportfolio/tests/models/test_roles.py,sha256=4Cn7WyrA2ztJNeWLk5cy9kYo5XLWMbFSvo1O-9JYxeA,3323
@@ -383,9 +383,9 @@ wbportfolio/tests/models/transactions/test_fees.py,sha256=1gp_h_CCC4Z_cWHUgrZCjG
383
383
  wbportfolio/tests/models/transactions/test_rebalancing.py,sha256=H1UbxPm-oa00-JJXbBxNutdTKvXXJmuATTab2XsXp44,3900
384
384
  wbportfolio/tests/models/transactions/test_trades.py,sha256=z0CCZjB648ECDSEdwmzqCdIe7h4UWVJQ8qKR42Xu0TQ,9008
385
385
  wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
386
- wbportfolio/tests/pms/test_analytics.py,sha256=FrvVsV_uUiTgmRUfsaB-_sGzY30CqknbOY2DvmwR_70,1141
386
+ wbportfolio/tests/pms/test_analytics.py,sha256=fAuY1zcXibttFpBh2GhKVyzdYfi1kz_b7SPa9xZQXY0,1086
387
387
  wbportfolio/tests/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
388
- wbportfolio/tests/rebalancing/test_models.py,sha256=wdlCfc6YxVT0oLxr45Q-LPKy4LjGbylkdxcfZozS0Fk,7565
388
+ wbportfolio/tests/rebalancing/test_models.py,sha256=mhhuIWPZF2SQHzVdotp90eIrfmnMGQNu5YTGcMDjlIY,7505
389
389
  wbportfolio/tests/serializers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
390
390
  wbportfolio/tests/serializers/test_claims.py,sha256=vQrg73xQXRFEgvx3KI9ivFre_wpBFzdO0p0J13PkvdY,582
391
391
  wbportfolio/tests/viewsets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -517,7 +517,7 @@ wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQV
517
517
  wbportfolio/viewsets/transactions/trade_proposals.py,sha256=gXdJJ00D6UavZfBczvZQb5cYPQlU6-xOg_-TnMBzEO0,4742
518
518
  wbportfolio/viewsets/transactions/trades.py,sha256=mo5b1wFm0twvGVp-CYnzpGLYMqPcHN8GjH4G_WwFFwc,16237
519
519
  wbportfolio/viewsets/transactions/transactions.py,sha256=ixDp-nsNA8t_A06rBCT19hOMJHy0iRmdz1XKdV1OwAs,4450
520
- wbportfolio-1.46.9.dist-info/METADATA,sha256=8tU84AIx2o33fnK6DQgvVs_OXm8g54lAacgMDS3Ep38,734
521
- wbportfolio-1.46.9.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
522
- wbportfolio-1.46.9.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
523
- wbportfolio-1.46.9.dist-info/RECORD,,
520
+ wbportfolio-1.46.10.dist-info/METADATA,sha256=ROqtFk4aUYi22kAtL59XSN67K2FQ_81K07XqnNfugRE,735
521
+ wbportfolio-1.46.10.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
522
+ wbportfolio-1.46.10.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
523
+ wbportfolio-1.46.10.dist-info/RECORD,,