wbportfolio 1.54.19__py2.py3-none-any.whl → 1.54.21__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,
@@ -42,82 +39,14 @@ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
42
39
  from wbportfolio.pms.typing import Position as PositionDTO
43
40
 
44
41
  from . import ProductGroup
45
- from .exceptions import InvalidAnalyticPortfolio
46
42
 
47
43
  logger = logging.getLogger("pms")
48
44
  if TYPE_CHECKING:
49
- from wbportfolio.models.orders.order_proposals import OrderProposal
45
+ pass
50
46
 
51
47
  MARKET_HOLIDAY_MAX_DURATION = 15
52
48
 
53
49
 
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
50
  class DefaultPortfolioQueryset(QuerySet):
122
51
  def filter_invested_at_date(self, val_date: date) -> QuerySet:
123
52
  """
@@ -217,6 +146,7 @@ class PortfolioPortfolioThroughModel(models.Model):
217
146
 
218
147
  class Portfolio(DeleteToDisableMixin, WBModel):
219
148
  assets: models.QuerySet[AssetPosition]
149
+ builder: AssetPositionBuilder
220
150
 
221
151
  name = models.CharField(
222
152
  max_length=255,
@@ -305,6 +235,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
305
235
  objects = DefaultPortfolioManager()
306
236
  tracked_objects = ActiveTrackedPortfolioManager()
307
237
 
238
+ def __init__(self, *args, **kwargs):
239
+ self.builder = AssetPositionBuilder(self)
240
+ super().__init__(*args, **kwargs)
241
+
308
242
  @property
309
243
  def primary_portfolio(self):
310
244
  with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
@@ -358,7 +292,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
358
292
  try:
359
293
  last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
360
294
  last_returns = last_returns.to_dict()
361
- except InvalidAnalyticPortfolio:
295
+ except ValueError:
362
296
  last_returns = {}
363
297
  positions = []
364
298
  for asset in assets:
@@ -387,7 +321,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
387
321
  )
388
322
 
389
323
  def get_analytic_portfolio(
390
- self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = False, **kwargs
324
+ self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
391
325
  ) -> AnalyticPortfolio:
392
326
  """
393
327
  Return the analytic portfolio associated with this portfolio at the given date
@@ -402,16 +336,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
402
336
  if not weights:
403
337
  weights = self.get_weights(val_date)
404
338
  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
- )
339
+ returns = self.builder.returns
340
+ if pd.Timestamp(return_date) not in returns.index or not set(weights.keys()).issubset(set(returns.columns)):
341
+ returns = self.load_builder_returns(val_date, return_date)
413
342
  if pd.Timestamp(return_date) not in returns.index:
414
- raise InvalidAnalyticPortfolio()
343
+ raise ValueError()
344
+ returns = returns.loc[:return_date, :]
415
345
  returns = returns.fillna(0) # not sure this is what we want
416
346
  return AnalyticPortfolio(
417
347
  X=returns,
@@ -712,7 +642,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
712
642
  recompute_weighting: bool = False,
713
643
  force_recompute_weighting: bool = False,
714
644
  evaluate_rebalancer: bool = True,
715
- changed_weights: dict[int, float] | None = None,
645
+ changed_portfolio: AnalyticPortfolio | None = None,
716
646
  broadcast_changes_at_date: bool = True,
717
647
  **kwargs,
718
648
  ):
@@ -743,7 +673,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
743
673
 
744
674
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
745
675
  self.estimate_net_asset_values(
746
- (val_date + BDay(1)).date(), weights=changed_weights
676
+ (val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
747
677
  ) # updating weighting in t0 influence nav in t+1
748
678
  if evaluate_rebalancer:
749
679
  self.evaluate_rebalancing(val_date)
@@ -760,7 +690,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
760
690
  val_date,
761
691
  recompute_weighting=recompute_weighting,
762
692
  force_recompute_weighting=force_recompute_weighting,
763
- changed_weights=changed_weights,
693
+ changed_portfolio=changed_portfolio,
764
694
  **kwargs,
765
695
  )
766
696
 
@@ -771,7 +701,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
771
701
  type=PortfolioPortfolioThroughModel.Type.PRIMARY,
772
702
  portfolio__is_lookthrough=True,
773
703
  ):
774
- rel.portfolio.compute_lookthrough(val_date, **kwargs)
704
+ rel.portfolio.compute_lookthrough(val_date)
775
705
  for rel in PortfolioPortfolioThroughModel.objects.filter(
776
706
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
777
707
  ):
@@ -791,97 +721,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
791
721
  logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
792
722
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
793
723
 
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
- },
724
+ def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
725
+ effective_portfolio_date = (val_date - BDay(1)).date()
726
+ with suppress(ValueError):
727
+ if not analytic_portfolio:
728
+ analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date)
729
+ for instrument in self.pms_instruments:
730
+ # 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
731
+ last_price = instrument.get_latest_price(effective_portfolio_date)
732
+ if (
733
+ instrument.is_active_at_date(val_date)
734
+ and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
735
+ and last_price
736
+ ):
737
+ logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
738
+ net_asset_value_computation_method = import_from_dotted_path(
739
+ net_asset_value_computation_method_path
811
740
  )
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()
741
+ estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
742
+ if estimated_net_asset_value is not None:
743
+ InstrumentPrice.objects.update_or_create(
744
+ instrument=instrument,
745
+ date=val_date,
746
+ calculated=True,
747
+ defaults={
748
+ "gross_value": estimated_net_asset_value,
749
+ "net_value": estimated_net_asset_value,
750
+ },
751
+ )
752
+ if (
753
+ val_date == instrument.last_price_date
754
+ ): # if price date is the latest instrument price date, we recompute the last valuation data
755
+ instrument.update_last_valuation_date()
816
756
 
817
757
  def drift_weights(
818
758
  self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
819
- ) -> tuple[AssetPositionIterator, "OrderProposal"]:
759
+ ) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
820
760
  logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
761
+
821
762
  rebalancer = getattr(self, "automatic_rebalancer", None)
822
763
  # Get initial weights
823
764
  weights = self.get_weights(start_date) # initial weights
824
765
  if not weights:
825
766
  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)
767
+ _, weights = next(self.drift_weights(previous_date, start_date))
768
+
842
769
  last_order_proposal = None
843
770
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
844
771
  to_date = to_date_ts.date()
845
772
  to_is_active = self.is_active_at_date(to_date)
846
773
  logger.info(f"Processing {to_date:%Y-%m-%d}")
847
-
774
+ order_proposal = None
848
775
  try:
849
- last_returns = returns.loc[[to_date_ts], :]
776
+ last_returns = self.builder.returns.loc[[to_date_ts], :]
850
777
  analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
851
778
  drifted_weights = analytic_portfolio.get_next_weights()
852
779
  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
- ]
780
+ break
781
+ try:
782
+ order_proposal = self.order_proposals.get(
783
+ trade_date=to_date, rebalancing_model__isnull=True, status="APPROVED"
865
784
  )
866
- last_order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
785
+ except ObjectDoesNotExist:
786
+ if rebalancer and rebalancer.is_valid(to_date):
787
+ effective_portfolio = PortfolioDTO(
788
+ positions=[
789
+ PositionDTO(
790
+ date=to_date,
791
+ underlying_instrument=i,
792
+ weighting=Decimal(w),
793
+ daily_return=Decimal(last_returns.iloc[-1][i]),
794
+ )
795
+ for i, w in weights.items()
796
+ ]
797
+ )
798
+ order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
799
+ if order_proposal:
800
+ last_order_proposal = order_proposal
867
801
  if stop_at_rebalancing:
868
802
  break
869
803
  next_weights = {
870
804
  trade.underlying_instrument.id: float(trade._target_weight)
871
- for trade in last_order_proposal.get_orders()
805
+ for trade in order_proposal.get_orders()
872
806
  }
873
- positions.add((to_date, next_weights), is_estimated=False)
807
+ yield to_date, next_weights
874
808
  else:
875
809
  next_weights = drifted_weights
876
810
  if to_is_active:
877
- positions.add((to_date, next_weights))
811
+ yield to_date, next_weights
878
812
  else:
879
- positions.add(
880
- (to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
813
+ yield (
814
+ to_date,
815
+ {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
881
816
  ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
882
817
  break
883
818
  weights = next_weights
884
- return positions, last_order_proposal
819
+ return last_order_proposal
885
820
 
886
821
  def propagate_or_update_assets(self, from_date: date, to_date: date):
887
822
  """
@@ -897,10 +832,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
897
832
  if (
898
833
  not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
899
834
  ): # 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
- )
835
+ self.load_builder_returns(from_date, to_date)
836
+ for pos_date, weights in self.drift_weights(from_date, to_date):
837
+ self.builder.add((pos_date, weights))
838
+ self.builder.bulk_create_positions(delete_leftovers=True)
839
+ self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
840
+ self.builder.schedule_metric_computation()
841
+
842
+ def load_builder_returns(self, from_date: date, to_date: date) -> pd.DataFrame:
843
+ instruments_ids = list(self.get_weights(from_date).keys())
844
+ for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
845
+ instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
846
+ self.builder.load_returns(set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date())
847
+ return self.builder.returns
904
848
 
905
849
  def get_lookthrough_positions(
906
850
  self,
@@ -970,12 +914,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
970
914
  assets = list(self.assets.filter(date=val_date))
971
915
  return assets
972
916
 
973
- def compute_lookthrough(self, from_date: date, to_date: date | None = None, **kwargs):
917
+ def compute_lookthrough(self, from_date: date, to_date: date | None = None):
974
918
  if not self.primary_portfolio or not self.is_lookthrough:
975
919
  raise ValueError(
976
920
  "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
977
921
  )
978
- positions = AssetPositionIterator(self)
979
922
  if not to_date:
980
923
  to_date = from_date
981
924
  for from_date in pd.date_range(from_date, to_date, freq="B").date:
@@ -983,10 +926,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
983
926
  portfolio_total_asset_value = (
984
927
  self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
985
928
  )
986
- positions.add(
929
+ self.builder.add(
987
930
  list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
988
931
  )
989
- self.bulk_create_positions(positions, delete_leftovers=True, **kwargs)
932
+ self.builder.bulk_create_positions(delete_leftovers=True)
933
+ self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
934
+ self.builder.schedule_metric_computation()
990
935
 
991
936
  def update_preferred_classification_per_instrument(self):
992
937
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -1043,64 +988,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1043
988
  def get_representation_label_key(cls):
1044
989
  return "{{name}}"
1045
990
 
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
991
  @classmethod
1105
992
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
1106
993
  if isinstance(portfolio_data, int):
@@ -1253,20 +1140,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1253
1140
  return portfolio
1254
1141
 
1255
1142
 
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))
1143
+ def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
1144
+ with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
1145
+ return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1270
1146
 
1271
1147
 
1272
1148
  @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)