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

@@ -38,7 +38,13 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
38
38
  help_text="The number of shares that were traded.",
39
39
  verbose_name="Shares",
40
40
  )
41
-
41
+ desired_target_weight = models.DecimalField(
42
+ max_digits=9,
43
+ decimal_places=ORDER_WEIGHTING_PRECISION,
44
+ default=Decimal(0),
45
+ help_text="Desired Target Weight (for compliance and audit)",
46
+ verbose_name="Desired Target Weight",
47
+ )
42
48
  weighting = models.DecimalField(
43
49
  max_digits=9,
44
50
  decimal_places=ORDER_WEIGHTING_PRECISION,
@@ -64,6 +70,7 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
64
70
 
65
71
  def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
66
72
  warnings = []
73
+
67
74
  # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
68
75
  if self.order_proposal and not self.portfolio.only_weighting:
69
76
  shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
@@ -77,11 +84,19 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
77
84
  self.shares = shares
78
85
  if portfolio_total_asset_value:
79
86
  self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
80
-
87
+ if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
88
+ warnings.append(
89
+ f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
90
+ )
91
+ self.shares = Decimal("0")
92
+ self.weighting = Decimal("0")
81
93
  if not self.price:
82
94
  warnings.append(f"No price for {self.underlying_instrument.computed_str}")
83
- if self._target_weight < 1e-8: # any value below -1e8 will be considered zero
95
+ if (
96
+ not self.underlying_instrument.is_cash and self._target_weight < -1e-8
97
+ ): # any value below -1e8 will be considered zero
84
98
  warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
99
+ self.desired_target_weight = self._target_weight
85
100
  return warnings
86
101
 
87
102
  @property
@@ -209,24 +224,6 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
209
224
  else:
210
225
  return Order.Type.SELL
211
226
 
212
- def get_asset(self) -> AssetPosition:
213
- asset = AssetPosition(
214
- underlying_quote=self.underlying_instrument,
215
- portfolio_created=None,
216
- portfolio=self.portfolio,
217
- date=self.value_date,
218
- initial_currency_fx_rate=self.currency_fx_rate,
219
- weighting=self._target_weight,
220
- initial_price=self.price,
221
- initial_shares=None,
222
- asset_valuation_date=self.value_date,
223
- currency=self.currency,
224
- is_estimated=False,
225
- )
226
- asset.set_weighting(self._target_weight)
227
- asset.pre_save()
228
- return asset
229
-
230
227
  def get_price(self) -> Decimal:
231
228
  try:
232
229
  return self.underlying_instrument.get_price(self.value_date)
@@ -3,34 +3,31 @@ from contextlib import suppress
3
3
  from datetime import date, timedelta
4
4
  from decimal import Decimal
5
5
  from math import isclose
6
- from typing import TYPE_CHECKING, Any, Iterable
6
+ from typing import TYPE_CHECKING, Any, Generator, Iterable
7
7
 
8
8
  import numpy as np
9
9
  import pandas as pd
10
10
  from celery import shared_task
11
- from django.contrib.contenttypes.models import ContentType
12
11
  from django.contrib.postgres.fields import DateRangeField
12
+ from django.core.exceptions import ObjectDoesNotExist
13
13
  from django.db import models
14
- from django.db.models import Case, Exists, F, OuterRef, Q, QuerySet, Sum, Value, When
15
- from django.db.models.functions import Coalesce
14
+ from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
16
15
  from django.db.models.signals import post_save
17
16
  from django.dispatch import receiver
18
17
  from django.utils import timezone
19
18
  from django.utils.functional import cached_property
20
19
  from pandas._libs.tslibs.offsets import BDay
21
- from skfolio.preprocessing import prices_to_returns
22
- from wbcore.contrib.currency.models import Currency, CurrencyFXRates
20
+ from wbcore.contrib.currency.models import Currency
23
21
  from wbcore.contrib.notifications.utils import create_notification_type
24
22
  from wbcore.models import WBModel
25
23
  from wbcore.utils.importlib import import_from_dotted_path
26
24
  from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
27
- from wbfdm.contrib.metric.tasks import compute_metrics_as_task
28
- from wbfdm.enums import MarketData
29
25
  from wbfdm.models import Instrument, InstrumentType
30
26
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
31
27
  from wbfdm.signals import investable_universe_updated
32
28
 
33
- from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
29
+ from wbportfolio.models.asset import AssetPosition
30
+ from wbportfolio.models.builder import AssetPositionBuilder
34
31
  from wbportfolio.models.indexes import Index
35
32
  from wbportfolio.models.portfolio_relationship import (
36
33
  InstrumentPortfolioThroughModel,
@@ -41,83 +38,16 @@ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
41
38
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
42
39
  from wbportfolio.pms.typing import Position as PositionDTO
43
40
 
41
+ from ..constants import EQUITY_TYPE_KEYS
44
42
  from . import ProductGroup
45
- from .exceptions import InvalidAnalyticPortfolio
46
43
 
47
44
  logger = logging.getLogger("pms")
48
45
  if TYPE_CHECKING:
49
- from wbportfolio.models.orders.order_proposals import OrderProposal
46
+ pass
50
47
 
51
48
  MARKET_HOLIDAY_MAX_DURATION = 15
52
49
 
53
50
 
54
- def get_returns(
55
- instrument_ids: list[int],
56
- from_date: date,
57
- to_date: date,
58
- to_currency: Currency | None = None,
59
- use_dl: bool = False,
60
- ) -> tuple[dict[date, dict[int, float]], pd.DataFrame]:
61
- """
62
- Utility methods to get instrument returns for a given date range
63
-
64
- Args:
65
- from_date: date range lower bound
66
- to_date: date range upper bound
67
- to_currency: currency to use for returns
68
- use_dl: whether to get data straight from the dataloader or use the internal table
69
-
70
- Returns:
71
- Return a tuple of the raw prices and the returns dataframe
72
- """
73
- if use_dl:
74
- kwargs = dict(from_date=from_date, to_date=to_date, values=[MarketData.CLOSE], apply_fx_rate=False)
75
- if to_currency:
76
- kwargs["target_currency"] = to_currency.key
77
- df = pd.DataFrame(Instrument.objects.filter(id__in=instrument_ids).dl.market_data(**kwargs))
78
- if df.empty:
79
- raise InvalidAnalyticPortfolio()
80
- df = df[["instrument_id", "fx_rate", "close", "valuation_date"]]
81
- else:
82
- if to_currency:
83
- fx_rate = Coalesce(
84
- CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency),
85
- Decimal("1"),
86
- )
87
- else:
88
- fx_rate = Value(Decimal("1"))
89
- # annotate fx rate only if the price is not calculated, in that case we assume the instrument is not tradable and we set a forex of None (to be fast forward filled)
90
- prices = InstrumentPrice.objects.filter(
91
- instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
92
- ).annotate(fx_rate=Case(When(calculated=False, then=fx_rate), default=None))
93
- df = pd.DataFrame(
94
- prices.filter_only_valid_prices().values_list("instrument", "fx_rate", "net_value", "date"),
95
- columns=["instrument_id", "fx_rate", "close", "valuation_date"],
96
- )
97
- if df.empty:
98
- raise InvalidAnalyticPortfolio()
99
- df = (
100
- df.pivot_table(index="valuation_date", columns="instrument_id", values=["fx_rate", "close"], dropna=False)
101
- .astype(float)
102
- .sort_index()
103
- )
104
-
105
- ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
106
- df = df.reindex(ts)
107
- df = df.ffill()
108
- df.index = pd.to_datetime(df.index)
109
- prices_df = df["close"]
110
-
111
- if "fx_rate" in df.columns:
112
- fx_rate_df = df["fx_rate"].fillna(1.0)
113
- else:
114
- fx_rate_df = pd.DataFrame(np.ones(prices_df.shape), index=prices_df.index, columns=prices_df.columns)
115
- returns = prices_to_returns(fx_rate_df * prices_df, drop_inceptions_nan=False, fill_nan=True)
116
- return {ts.date(): row for ts, row in prices_df.to_dict("index").items()}, returns.replace(
117
- [np.inf, -np.inf, np.nan], 0
118
- )
119
-
120
-
121
51
  class DefaultPortfolioQueryset(QuerySet):
122
52
  def filter_invested_at_date(self, val_date: date) -> QuerySet:
123
53
  """
@@ -217,6 +147,7 @@ class PortfolioPortfolioThroughModel(models.Model):
217
147
 
218
148
  class Portfolio(DeleteToDisableMixin, WBModel):
219
149
  assets: models.QuerySet[AssetPosition]
150
+ builder: AssetPositionBuilder
220
151
 
221
152
  name = models.CharField(
222
153
  max_length=255,
@@ -305,6 +236,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
305
236
  objects = DefaultPortfolioManager()
306
237
  tracked_objects = ActiveTrackedPortfolioManager()
307
238
 
239
+ def __init__(self, *args, **kwargs):
240
+ self.builder = AssetPositionBuilder(self)
241
+ super().__init__(*args, **kwargs)
242
+
308
243
  @property
309
244
  def primary_portfolio(self):
310
245
  with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
@@ -358,7 +293,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
358
293
  try:
359
294
  last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
360
295
  last_returns = last_returns.to_dict()
361
- except InvalidAnalyticPortfolio:
296
+ except ValueError:
362
297
  last_returns = {}
363
298
  positions = []
364
299
  for asset in assets:
@@ -387,7 +322,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
387
322
  )
388
323
 
389
324
  def get_analytic_portfolio(
390
- self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = False, **kwargs
325
+ self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
391
326
  ) -> AnalyticPortfolio:
392
327
  """
393
328
  Return the analytic portfolio associated with this portfolio at the given date
@@ -402,16 +337,16 @@ class Portfolio(DeleteToDisableMixin, WBModel):
402
337
  if not weights:
403
338
  weights = self.get_weights(val_date)
404
339
  return_date = (val_date + BDay(1)).date()
405
- _, returns = get_returns(
406
- list(weights.keys()),
407
- (val_date - BDay(MARKET_HOLIDAY_MAX_DURATION)).date(),
408
- return_date,
409
- to_currency=self.currency,
410
- use_dl=use_dl,
411
- **kwargs,
412
- )
340
+ returns = self.builder.returns
341
+ if (
342
+ return_date <= date.today()
343
+ and pd.Timestamp(return_date) not in returns.index
344
+ or not set(weights.keys()).issubset(set(returns.columns))
345
+ ):
346
+ returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl)
413
347
  if pd.Timestamp(return_date) not in returns.index:
414
- raise InvalidAnalyticPortfolio()
348
+ raise ValueError()
349
+ returns = returns.loc[:return_date, :]
415
350
  returns = returns.fillna(0) # not sure this is what we want
416
351
  return AnalyticPortfolio(
417
352
  X=returns,
@@ -577,7 +512,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
577
512
  val_date=val_date,
578
513
  exclude_cash=True,
579
514
  exclude_index=True,
580
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
515
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
581
516
  **kwargs,
582
517
  )
583
518
  if not df.empty:
@@ -590,7 +525,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
590
525
  val_date=val_date,
591
526
  exclude_cash=True,
592
527
  exclude_index=True,
593
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
528
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
594
529
  **kwargs,
595
530
  )
596
531
  if not df.empty:
@@ -662,7 +597,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
662
597
  ) -> pd.DataFrame:
663
598
  qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
664
599
  if only_equity:
665
- qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
600
+ qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
666
601
  qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
667
602
  df = Portfolio.get_contribution_df(
668
603
  qs.select_related("underlying_instrument").values_list(
@@ -712,7 +647,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
712
647
  recompute_weighting: bool = False,
713
648
  force_recompute_weighting: bool = False,
714
649
  evaluate_rebalancer: bool = True,
715
- changed_weights: dict[int, float] | None = None,
650
+ changed_portfolio: AnalyticPortfolio | None = None,
716
651
  broadcast_changes_at_date: bool = True,
717
652
  **kwargs,
718
653
  ):
@@ -743,7 +678,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
743
678
 
744
679
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
745
680
  self.estimate_net_asset_values(
746
- (val_date + BDay(1)).date(), weights=changed_weights
681
+ (val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
747
682
  ) # updating weighting in t0 influence nav in t+1
748
683
  if evaluate_rebalancer:
749
684
  self.evaluate_rebalancing(val_date)
@@ -760,7 +695,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
760
695
  val_date,
761
696
  recompute_weighting=recompute_weighting,
762
697
  force_recompute_weighting=force_recompute_weighting,
763
- changed_weights=changed_weights,
698
+ changed_portfolio=changed_portfolio,
764
699
  **kwargs,
765
700
  )
766
701
 
@@ -771,7 +706,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
771
706
  type=PortfolioPortfolioThroughModel.Type.PRIMARY,
772
707
  portfolio__is_lookthrough=True,
773
708
  ):
774
- rel.portfolio.compute_lookthrough(val_date, **kwargs)
709
+ rel.portfolio.compute_lookthrough(val_date)
775
710
  for rel in PortfolioPortfolioThroughModel.objects.filter(
776
711
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
777
712
  ):
@@ -791,97 +726,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
791
726
  logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
792
727
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
793
728
 
794
- def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
795
- for instrument in self.pms_instruments:
796
- if instrument.is_active_at_date(val_date) and (
797
- net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
798
- ):
799
- logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
800
- net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
801
- estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
802
- if estimated_net_asset_value is not None:
803
- InstrumentPrice.objects.update_or_create(
804
- instrument=instrument,
805
- date=val_date,
806
- calculated=True,
807
- defaults={
808
- "gross_value": estimated_net_asset_value,
809
- "net_value": estimated_net_asset_value,
810
- },
729
+ def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
730
+ effective_portfolio_date = (val_date - BDay(1)).date()
731
+ with suppress(ValueError):
732
+ if not analytic_portfolio:
733
+ analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
734
+ for instrument in self.pms_instruments:
735
+ # 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
736
+ last_price = instrument.get_latest_price(effective_portfolio_date)
737
+ if (
738
+ instrument.is_active_at_date(val_date)
739
+ and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
740
+ and last_price
741
+ ):
742
+ logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
743
+ net_asset_value_computation_method = import_from_dotted_path(
744
+ net_asset_value_computation_method_path
811
745
  )
812
- if (
813
- val_date == instrument.last_price_date
814
- ): # if price date is the latest instrument price date, we recompute the last valuation data
815
- instrument.update_last_valuation_date()
746
+ estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
747
+ if estimated_net_asset_value is not None:
748
+ InstrumentPrice.objects.update_or_create(
749
+ instrument=instrument,
750
+ date=val_date,
751
+ calculated=True,
752
+ defaults={
753
+ "gross_value": estimated_net_asset_value,
754
+ "net_value": estimated_net_asset_value,
755
+ },
756
+ )
757
+ if (
758
+ val_date == instrument.last_price_date
759
+ ): # if price date is the latest instrument price date, we recompute the last valuation data
760
+ instrument.update_last_valuation_date()
816
761
 
817
762
  def drift_weights(
818
763
  self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
819
- ) -> tuple[AssetPositionIterator, "OrderProposal"]:
764
+ ) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
820
765
  logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
766
+
821
767
  rebalancer = getattr(self, "automatic_rebalancer", None)
822
768
  # Get initial weights
823
769
  weights = self.get_weights(start_date) # initial weights
824
770
  if not weights:
825
771
  previous_date = self.assets.filter(date__lte=start_date).latest("date").date
826
- drifted_positions, _ = self.drift_weights(previous_date, start_date)
827
- weights = drifted_positions.get_weights()[start_date]
828
-
829
- # Get returns and prices data for the whole date range
830
- instrument_ids = list(weights.keys())
831
-
832
- prices, returns = get_returns(
833
- instrument_ids,
834
- (start_date - BDay(MARKET_HOLIDAY_MAX_DURATION)).date(),
835
- end_date,
836
- to_currency=self.currency,
837
- use_dl=True,
838
- )
839
- # Get raw prices to speed up asset position creation
840
- # Instantiate the position iterator with the initial weights
841
- positions = AssetPositionIterator(self, prices=prices)
772
+ _, weights = next(self.drift_weights(previous_date, start_date))
773
+
842
774
  last_order_proposal = None
843
775
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
844
776
  to_date = to_date_ts.date()
845
777
  to_is_active = self.is_active_at_date(to_date)
846
778
  logger.info(f"Processing {to_date:%Y-%m-%d}")
847
-
779
+ order_proposal = None
848
780
  try:
849
- last_returns = returns.loc[[to_date_ts], :]
781
+ last_returns = self.builder.returns.loc[[to_date_ts], :]
850
782
  analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
851
783
  drifted_weights = analytic_portfolio.get_next_weights()
852
784
  except KeyError: # if no return for that date, we break and continue
853
- drifted_weights = weights
854
- if rebalancer and rebalancer.is_valid(to_date):
855
- effective_portfolio = PortfolioDTO(
856
- positions=[
857
- PositionDTO(
858
- date=to_date,
859
- underlying_instrument=i,
860
- weighting=Decimal(w),
861
- daily_return=Decimal(last_returns.iloc[-1][i]),
862
- )
863
- for i, w in weights.items()
864
- ]
785
+ break
786
+ try:
787
+ order_proposal = self.order_proposals.get(
788
+ trade_date=to_date, rebalancing_model__isnull=True, status="APPROVED"
865
789
  )
866
- last_order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
790
+ except ObjectDoesNotExist:
791
+ if rebalancer and rebalancer.is_valid(to_date):
792
+ effective_portfolio = PortfolioDTO(
793
+ positions=[
794
+ PositionDTO(
795
+ date=to_date,
796
+ underlying_instrument=i,
797
+ weighting=Decimal(w),
798
+ daily_return=Decimal(last_returns.iloc[-1][i]),
799
+ )
800
+ for i, w in weights.items()
801
+ ]
802
+ )
803
+ order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
804
+ if order_proposal:
805
+ last_order_proposal = order_proposal
867
806
  if stop_at_rebalancing:
868
807
  break
869
808
  next_weights = {
870
809
  trade.underlying_instrument.id: float(trade._target_weight)
871
- for trade in last_order_proposal.get_orders()
810
+ for trade in order_proposal.get_orders()
872
811
  }
873
- positions.add((to_date, next_weights), is_estimated=False)
812
+ yield to_date, next_weights
874
813
  else:
875
814
  next_weights = drifted_weights
876
815
  if to_is_active:
877
- positions.add((to_date, next_weights))
816
+ yield to_date, next_weights
878
817
  else:
879
- positions.add(
880
- (to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
818
+ yield (
819
+ to_date,
820
+ {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
881
821
  ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
882
822
  break
883
823
  weights = next_weights
884
- return positions, last_order_proposal
824
+ return last_order_proposal
885
825
 
886
826
  def propagate_or_update_assets(self, from_date: date, to_date: date):
887
827
  """
@@ -897,10 +837,21 @@ class Portfolio(DeleteToDisableMixin, WBModel):
897
837
  if (
898
838
  not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
899
839
  ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
900
- positions, _ = self.drift_weights(from_date, to_date)
901
- self.bulk_create_positions(
902
- positions, delete_leftovers=True, compute_metrics=True, evaluate_rebalancer=False
903
- )
840
+ self.load_builder_returns(from_date, to_date)
841
+ for pos_date, weights in self.drift_weights(from_date, to_date):
842
+ self.builder.add((pos_date, weights))
843
+ self.builder.bulk_create_positions(delete_leftovers=True)
844
+ self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
845
+ self.builder.schedule_metric_computation()
846
+
847
+ def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
848
+ instruments_ids = list(self.get_weights(from_date).keys())
849
+ for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
850
+ instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
851
+ self.builder.load_returns(
852
+ set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
853
+ )
854
+ return self.builder.returns
904
855
 
905
856
  def get_lookthrough_positions(
906
857
  self,
@@ -970,12 +921,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
970
921
  assets = list(self.assets.filter(date=val_date))
971
922
  return assets
972
923
 
973
- def compute_lookthrough(self, from_date: date, to_date: date | None = None, **kwargs):
924
+ def compute_lookthrough(self, from_date: date, to_date: date | None = None):
974
925
  if not self.primary_portfolio or not self.is_lookthrough:
975
926
  raise ValueError(
976
927
  "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
977
928
  )
978
- positions = AssetPositionIterator(self)
979
929
  if not to_date:
980
930
  to_date = from_date
981
931
  for from_date in pd.date_range(from_date, to_date, freq="B").date:
@@ -983,10 +933,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
983
933
  portfolio_total_asset_value = (
984
934
  self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
985
935
  )
986
- positions.add(
936
+ self.builder.add(
987
937
  list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
988
938
  )
989
- self.bulk_create_positions(positions, delete_leftovers=True, **kwargs)
939
+ self.builder.bulk_create_positions(delete_leftovers=True)
940
+ self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
941
+ self.builder.schedule_metric_computation()
990
942
 
991
943
  def update_preferred_classification_per_instrument(self):
992
944
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -1043,64 +995,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1043
995
  def get_representation_label_key(cls):
1044
996
  return "{{name}}"
1045
997
 
1046
- def bulk_create_positions(
1047
- self,
1048
- positions: AssetPositionIterator,
1049
- delete_leftovers: bool = False,
1050
- force_save: bool = False,
1051
- compute_metrics: bool = False,
1052
- **kwargs,
1053
- ):
1054
- if positions:
1055
- # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
1056
- # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
1057
- # change completely the trades of a portfolio model and drift it.
1058
-
1059
- dates = positions.get_dates()
1060
- self.assets.filter(date__in=dates, is_estimated=True).delete()
1061
-
1062
- if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
1063
- leftover_positions_ids = list(
1064
- self.assets.filter(date__in=dates).values_list("id", flat=True)
1065
- ) # we need to get the ids otherwise the queryset is reevaluated later
1066
- positions_list = list(positions)
1067
- logger.info(
1068
- f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
1069
- )
1070
- objs = AssetPosition.unannotated_objects.bulk_create(
1071
- positions_list,
1072
- update_fields=[
1073
- "weighting",
1074
- "initial_price",
1075
- "initial_currency_fx_rate",
1076
- "initial_shares",
1077
- "currency_fx_rate_instrument_to_usd",
1078
- "currency_fx_rate_portfolio_to_usd",
1079
- "underlying_quote_price",
1080
- "portfolio",
1081
- "portfolio_created",
1082
- "underlying_instrument",
1083
- ],
1084
- unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
1085
- update_conflicts=True,
1086
- batch_size=10000,
1087
- )
1088
- if delete_leftovers:
1089
- objs_ids = list(map(lambda x: x.id, objs))
1090
- leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
1091
- logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
1092
- AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
1093
- if compute_metrics and self.is_tracked:
1094
- for val_date in dates:
1095
- compute_metrics_as_task.delay(
1096
- val_date,
1097
- basket_id=self.id,
1098
- basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id,
1099
- )
1100
- for update_date, changed_weights in positions.get_weights().items():
1101
- kwargs.pop("changed_weights", None)
1102
- self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
1103
-
1104
998
  @classmethod
1105
999
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
1106
1000
  if isinstance(portfolio_data, int):
@@ -1253,20 +1147,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1253
1147
  return portfolio
1254
1148
 
1255
1149
 
1256
- def default_estimate_net_value(
1257
- val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
1258
- ) -> float | None:
1259
- portfolio: Portfolio = instrument.portfolio
1260
- previous_val_date = (val_date - BDay(1)).date()
1261
- if not weights:
1262
- weights = portfolio.get_weights(previous_val_date)
1263
- # 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
1264
- if weights and (last_price := instrument.get_latest_price(previous_val_date)):
1265
- with suppress(
1266
- IndexError, InvalidAnalyticPortfolio
1267
- ): # we silent any indexerror introduced by no returns for the past days
1268
- analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
1269
- return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1150
+ def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
1151
+ with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
1152
+ return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1270
1153
 
1271
1154
 
1272
1155
  @receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
@@ -92,6 +92,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
92
92
  "total_cash_weight",
93
93
  "comment",
94
94
  "status",
95
+ "min_order_value",
95
96
  "portfolio",
96
97
  "_rebalancing_model",
97
98
  "rebalancing_model",
@@ -383,7 +383,7 @@ class TestOrderProposal:
383
383
  """
384
384
  Ensure replaying order proposals correctly calls drift_weights for each period.
385
385
  """
386
- mock_fct.return_value = None, None
386
+ mock_fct.return_value = iter([])
387
387
 
388
388
  # Create approved order proposals for testing
389
389
  tp0 = order_proposal_factory.create(status=OrderProposal.Status.APPROVED)
@@ -672,3 +672,18 @@ class TestOrderProposal:
672
672
  assert Order.objects.get(
673
673
  order_proposal=draft_tp, underlying_instrument=trade.underlying_instrument
674
674
  ).weighting == Decimal("0")
675
+
676
+ def test_order_submit_bellow_minimum_allowed_order_value(self, order_factory):
677
+ order = order_factory.create(price=Decimal(1), weighting=Decimal(1), shares=Decimal(999))
678
+ order.submit()
679
+ order.save()
680
+ assert order.shares == Decimal(999)
681
+ assert order.weighting == Decimal(1)
682
+
683
+ order.order_proposal.min_order_value = Decimal(1000)
684
+ order.order_proposal.save()
685
+
686
+ order.submit()
687
+ order.save()
688
+ assert order.shares == Decimal(0)
689
+ assert order.weighting == Decimal(0)