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

wbportfolio/apps.py CHANGED
@@ -1,5 +1,21 @@
1
1
  from django.apps import AppConfig
2
+ from django.apps import apps as global_apps
3
+ from django.db import DEFAULT_DB_ALIAS
4
+ from django.db.models.signals import post_migrate
5
+ from django.utils.module_loading import autodiscover_modules
2
6
 
3
7
 
4
8
  class WbportfolioConfig(AppConfig):
5
9
  name = "wbportfolio"
10
+
11
+ def ready(self):
12
+ def autodiscover_backends(
13
+ app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs
14
+ ):
15
+ # we wrap the autodiscover into a post_migrate receiver because we expect db calls
16
+ autodiscover_modules("rebalancing")
17
+
18
+ post_migrate.connect(
19
+ autodiscover_backends,
20
+ dispatch_uid="wbportfolio.autodiscover_rebalancing",
21
+ )
@@ -53,13 +53,6 @@ class AssetPositionImportHandler(ImportExportHandler):
53
53
  underlying_quote_data, only_security=False, read_only=True
54
54
  )[0]
55
55
 
56
- # number type deserialization and sanitization
57
- # ensure the provided Decimal field are of type Decimal
58
- decimal_fields = ["initial_currency_fx_rate", "initial_price", "initial_shares", "weighting"]
59
- for field in decimal_fields:
60
- if not (value := data.get(field, None)) is None:
61
- data[field] = Decimal(value)
62
-
63
56
  # Ensure that for shares and weighting, a None value default to 0
64
57
  if "initial_shares" in data and data["initial_shares"] is None:
65
58
  data["initial_shares"] = Decimal(0)
@@ -81,6 +74,13 @@ class AssetPositionImportHandler(ImportExportHandler):
81
74
  except ValueError:
82
75
  raise DeserializationError("Price not provided but can not be found automatically")
83
76
 
77
+ # number type deserialization and sanitization
78
+ # ensure the provided Decimal field are of type Decimal
79
+ decimal_fields = ["initial_currency_fx_rate", "initial_price", "initial_shares", "weighting"]
80
+ for field in decimal_fields:
81
+ if not (value := data.get(field, None)) is None:
82
+ data[field] = Decimal(value)
83
+
84
84
  def _process_raw_data(self, data: Dict[str, Any]):
85
85
  if prices := data.get("prices", None):
86
86
  self.import_source.log += "Instrument Prices found: Importing"
@@ -1,6 +1,4 @@
1
- import math
2
1
  from datetime import datetime
3
- from decimal import Decimal
4
2
  from typing import Any, Dict, Optional
5
3
 
6
4
  from django.db import models
@@ -28,43 +26,45 @@ class TradeImportHandler(ImportExportHandler):
28
26
 
29
27
  def _data_changed(self, _object, change_data: Dict[str, Any], initial_data: Dict[str, Any], **kwargs):
30
28
  if (new_register := change_data.get("register")) and (current_register := _object.register):
31
- # we remplace the register only if the new one gives us more information
29
+ # we replace the register only if the new one gives us more information
32
30
  if new_register.register_reference == current_register.global_register_reference:
33
31
  del change_data["register"]
34
32
  return super()._data_changed(_object, change_data, initial_data, **kwargs)
35
33
 
36
34
  def _deserialize(self, data: Dict[str, Any]):
37
- if external_identifier2 := data.get("external_identifier2", None):
38
- data["external_identifier2"] = str(external_identifier2)
39
- if transaction_date_str := data.get("transaction_date", None):
40
- data["transaction_date"] = datetime.strptime(transaction_date_str, "%Y-%m-%d").date()
41
- if value_date_str := data.get("value_date", None):
42
- data["value_date"] = datetime.strptime(value_date_str, "%Y-%m-%d").date()
43
- if book_date_str := data.get("book_date", None):
44
- data["book_date"] = datetime.strptime(book_date_str, "%Y-%m-%d").date()
35
+ from wbportfolio.models.transactions.trade_proposals import TradeProposal
36
+
45
37
  if underlying_instrument := data.get("underlying_instrument", None):
46
38
  data["underlying_instrument"] = self.instrument_handler.process_object(
47
39
  underlying_instrument, only_security=False, read_only=True
48
40
  )[0]
49
- data["portfolio"] = Portfolio._get_or_create_portfolio(
50
- self.instrument_handler, data.get("portfolio", data["underlying_instrument"])
51
- )
52
- if currency_data := data.get("currency", None):
53
- data["currency"] = self.currency_handler.process_object(currency_data, read_only=True)[0]
54
41
 
55
- if register_data := data.get("register", None):
56
- data["register"] = self.register_handler.process_object(register_data)[0]
42
+ if trade_proposal_id := data.pop("trade_proposal_id", None):
43
+ trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
44
+ data["value_date"] = trade_proposal.last_effective_date
45
+ data["transaction_date"] = trade_proposal.trade_date
46
+ data["trade_proposal"] = trade_proposal
47
+ data["portfolio"] = trade_proposal.portfolio
48
+ data["status"] = "DRAFT"
49
+ else:
50
+ if external_identifier2 := data.get("external_identifier2", None):
51
+ data["external_identifier2"] = str(external_identifier2)
52
+ if transaction_date_str := data.get("transaction_date", None):
53
+ data["transaction_date"] = datetime.strptime(transaction_date_str, "%Y-%m-%d").date()
54
+ if value_date_str := data.get("value_date", None):
55
+ data["value_date"] = datetime.strptime(value_date_str, "%Y-%m-%d").date()
56
+ if book_date_str := data.get("book_date", None):
57
+ data["book_date"] = datetime.strptime(book_date_str, "%Y-%m-%d").date()
58
+ data["portfolio"] = Portfolio._get_or_create_portfolio(
59
+ self.instrument_handler, data.get("portfolio", data["underlying_instrument"])
60
+ )
61
+ if currency_data := data.get("currency", None):
62
+ data["currency"] = self.currency_handler.process_object(currency_data, read_only=True)[0]
57
63
 
58
- for field in self.model._meta.get_fields():
59
- if not (value := data.get(field.name, None)) is None and isinstance(field, models.DecimalField):
60
- q = 1 / (math.pow(10, 4))
61
- data[field.name] = Decimal(value).quantize(Decimal(str(q)))
64
+ if register_data := data.get("register", None):
65
+ data["register"] = self.register_handler.process_object(register_data)[0]
62
66
 
63
- if "total_value" in data:
64
- data["total_value"] = data["price"] * data["shares"]
65
- if "total_value_fx_portfolio" in data:
66
- data["total_value_fx_portfolio"] = data["price"] * data["shares"] * data["currency_fx_rate"]
67
- data["marked_for_deletion"] = data.get("marked_for_deletion", False)
67
+ data["marked_for_deletion"] = data.get("marked_for_deletion", False)
68
68
 
69
69
  def _create_instance(self, data: Dict[str, Any], **kwargs) -> models.Model:
70
70
  if "transaction_date" not in data: # we might get only book date and not transaction date
@@ -81,7 +81,7 @@ class TradeImportHandler(ImportExportHandler):
81
81
  dates_lookup = {"book_date": book_date}
82
82
  else:
83
83
  raise DeserializationError("date lookup is missing from data")
84
- self.import_source.log += f"\nParameter: Product={data['underlying_instrument']} Trade-Date={transaction_date} Shares={data['shares']}"
84
+ self.import_source.log += f"\nParameter: Product={data['underlying_instrument']} Trade-Date={transaction_date} Shares={data.get('shares')} Weighting={data.get('weighting')}"
85
85
 
86
86
  if history.exists():
87
87
  queryset = history
@@ -89,10 +89,10 @@ class TradeImportHandler(ImportExportHandler):
89
89
  queryset = self.model.objects.filter(marked_for_deletion=False)
90
90
 
91
91
  queryset = queryset.filter(
92
- models.Q(underlying_instrument=data["underlying_instrument"])
93
- & models.Q(**dates_lookup)
94
- & models.Q(shares=data["shares"])
92
+ models.Q(underlying_instrument=data["underlying_instrument"]) & models.Q(**dates_lookup)
95
93
  )
94
+ if "shares" in data:
95
+ queryset = queryset.filter(shares=data["shares"])
96
96
 
97
97
  if _id := data.get("id", None):
98
98
  self.import_source.log += f"ID {_id} provided -> Load CustomerTrade"
@@ -108,7 +108,7 @@ class TradeImportHandler(ImportExportHandler):
108
108
  if portfolio := data.get("portfolio", None):
109
109
  queryset = queryset.filter(portfolio=portfolio)
110
110
  if queryset.exists():
111
- if bank := data["bank"]:
111
+ if bank := data.get("bank"):
112
112
  self.import_source.log += (
113
113
  f"\n{queryset.count()} Trades found. The bank will tried to be matched against {bank}"
114
114
  )
@@ -138,24 +138,27 @@ class TradeImportHandler(ImportExportHandler):
138
138
  self.import_source.log += "\nNo trade was successfully matched."
139
139
 
140
140
  def _get_history(self, history: Dict[str, Any]) -> models.QuerySet:
141
- trades = self.model.objects.filter(
142
- exclude_from_history=False,
143
- pending=False,
144
- transaction_subtype__in=[
145
- self.model.Type.SUBSCRIPTION,
146
- self.model.Type.REDEMPTION,
147
- ], # we cannot exclude marked for deleted trade because otherwise they are never consider in the history
148
- )
149
- if transaction_date := history.get("transaction_date"):
150
- trades = trades.filter(transaction_date__lte=transaction_date)
151
- elif book_date := history.get("book_date"):
152
- trades = trades.filter(book_date__lte=book_date)
153
- if "underlying_instrument" in history:
154
- trades = trades.filter(underlying_instrument__id=history["underlying_instrument"])
155
- elif "underlying_instruments" in history:
156
- trades = trades.filter(underlying_instrument__id__in=history["underlying_instruments"])
141
+ if trade_proposal_id := history.get("trade_proposal_id"):
142
+ trades = self.model.objects.filter(trade_proposal_id=trade_proposal_id)
157
143
  else:
158
- raise ValueError("We cannot estimate history without at least the underlying instrument")
144
+ trades = self.model.objects.filter(
145
+ exclude_from_history=False,
146
+ pending=False,
147
+ transaction_subtype__in=[
148
+ self.model.Type.SUBSCRIPTION,
149
+ self.model.Type.REDEMPTION,
150
+ ], # we cannot exclude marked for deleted trade because otherwise they are never consider in the history
151
+ )
152
+ if transaction_date := history.get("transaction_date"):
153
+ trades = trades.filter(transaction_date__lte=transaction_date)
154
+ elif book_date := history.get("book_date"):
155
+ trades = trades.filter(book_date__lte=book_date)
156
+ if "underlying_instrument" in history:
157
+ trades = trades.filter(underlying_instrument__id=history["underlying_instrument"])
158
+ elif "underlying_instruments" in history:
159
+ trades = trades.filter(underlying_instrument__id__in=history["underlying_instruments"])
160
+ else:
161
+ raise ValueError("We cannot estimate history without at least the underlying instrument")
159
162
  return trades
160
163
 
161
164
  def _post_processing_objects(
@@ -188,5 +191,8 @@ class TradeImportHandler(ImportExportHandler):
188
191
  self.import_source.log += (
189
192
  f"{trade.transaction_date:%d.%m.%Y}: {trade.shares} {trade.bank} ==> Marked for deletion"
190
193
  )
191
- trade.marked_for_deletion = True
192
- trade.save()
194
+ if trade.trade_proposal:
195
+ trade.delete()
196
+ else:
197
+ trade.marked_for_deletion = True
198
+ trade.save()
@@ -5,7 +5,7 @@ from import_export.widgets import ForeignKeyWidget
5
5
  from wbcore.contrib.io.resources import FilterModelResource
6
6
  from wbfdm.models import Instrument
7
7
 
8
- from wbportfolio.models import Trade
8
+ from wbportfolio.models import Trade, TradeProposal
9
9
 
10
10
  fake = Faker()
11
11
 
@@ -15,6 +15,30 @@ class TradeProposalTradeResource(FilterModelResource):
15
15
  Trade Resource class to use to import trade from the trade proposal
16
16
  """
17
17
 
18
+ def __init__(self, **kwargs):
19
+ self.trade_proposal = TradeProposal.objects.get(pk=kwargs["trade_proposal_id"])
20
+ super().__init__(**kwargs)
21
+
22
+ def before_import(self, dataset, **kwargs):
23
+ Trade.objects.filter(trade_proposal=self.trade_proposal).delete()
24
+
25
+ def get_or_init_instance(self, instance_loader, row):
26
+ try:
27
+ return Trade.objects.get(
28
+ trade_proposal=self.trade_proposal, underlying_instrument=row["underlying_instrument"]
29
+ )
30
+ except Trade.DoesNotExist:
31
+ return Trade(
32
+ trade_proposal=self.trade_proposal,
33
+ underlying_instrument=row["underlying_instrument"],
34
+ transaction_subtype=Trade.Type.BUY if row["weighting"] > 0 else Trade.Type.SELL,
35
+ currency=row["underlying_instrument"].currency,
36
+ transaction_date=self.trade_proposal.trade_date,
37
+ portfolio=self.trade_proposal.portfolio,
38
+ weighting=row["weighting"],
39
+ status=Trade.Status.DRAFT,
40
+ )
41
+
18
42
  DUMMY_FIELD_MAP = {
19
43
  "underlying_instrument": lambda: rstr.xeger("([A-Z]{2}[A-Z0-9]{9}[0-9]{1})"),
20
44
  "weighting": 1.0,
@@ -29,7 +53,7 @@ class TradeProposalTradeResource(FilterModelResource):
29
53
  )
30
54
 
31
55
  class Meta:
32
- import_id_fields = ("id",)
56
+ import_id_fields = ("underlying_instrument",)
33
57
  fields = (
34
58
  "id",
35
59
  "underlying_instrument",
@@ -0,0 +1,24 @@
1
+ # Generated by Django 5.0.12 on 2025-02-14 13:10
2
+
3
+ import django_fsm
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('wbportfolio', '0073_remove_product_price_computation_and_more'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterField(
15
+ model_name='rebalancer',
16
+ name='frequency',
17
+ field=models.CharField(default='RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1', help_text='The Evaluation Frequency in RRULE format', max_length=256, verbose_name='Evaluation Frequency'),
18
+ ),
19
+ migrations.AlterField(
20
+ model_name='tradeproposal',
21
+ name='status',
22
+ field=django_fsm.FSMField(choices=[('DRAFT', 'Draft'), ('SUBMIT', 'Submit'), ('APPROVED', 'Approved'), ('DENIED', 'Denied'), ('FAILED', 'Failed')], default='DRAFT', max_length=50, verbose_name='Status'),
23
+ ),
24
+ ]
@@ -61,6 +61,15 @@ class PMSInstrument(Instrument):
61
61
  def nominal_value(self, val_date):
62
62
  return self.total_shares(val_date) * self.share_price
63
63
 
64
+ def get_latest_price(self, val_date: date) -> InstrumentPrice | None:
65
+ try:
66
+ return InstrumentPrice.objects.filter_only_valid_prices().get(instrument=self, date=val_date)
67
+ except InstrumentPrice.DoesNotExist:
68
+ if not self.inception_date or not self.prices.filter(date__lte=val_date).exists():
69
+ return InstrumentPrice.objects.get_or_create(
70
+ instrument=self, date=val_date, defaults={"calculated": False, "net_value": self.issue_price}
71
+ )[0]
72
+
64
73
  def get_latest_valid_price(self, val_date: Optional[date] = None) -> models.Model:
65
74
  qs = self.valuations.exclude(net_value=0)
66
75
  if val_date and qs.filter(date__lte=val_date).exists():
@@ -112,7 +112,7 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
112
112
  return (
113
113
  super()
114
114
  .get_queryset()
115
- .annotate(asset_exists=Exists(AssetPosition.objects.filter(portfolio=OuterRef("pk"))))
115
+ .annotate(asset_exists=Exists(AssetPosition.unannotated_objects.filter(portfolio=OuterRef("pk"))))
116
116
  .filter(asset_exists=True, is_tracked=True)
117
117
  )
118
118
 
@@ -569,9 +569,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
569
569
  child_portfolios = set()
570
570
  if pms_instruments := list(self.pms_instruments):
571
571
  for parent_portfolio in Portfolio.objects.filter(
572
- id__in=AssetPosition.objects.filter(date=val_date, underlying_quote__in=pms_instruments).values(
573
- "portfolio"
574
- )
572
+ id__in=AssetPosition.unannotated_objects.filter(
573
+ date=val_date, underlying_quote__in=pms_instruments
574
+ ).values("portfolio")
575
575
  ):
576
576
  child_portfolios.add(parent_portfolio)
577
577
  return child_portfolios
@@ -589,6 +589,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
589
589
  recompute_weighting: bool = False,
590
590
  force_recompute_weighting: bool = False,
591
591
  compute_metrics: bool = False,
592
+ evaluate_rebalancer: bool = True,
592
593
  ):
593
594
  logger.info(f"change at date for {self} at {val_date}")
594
595
  qs = self.assets.filter(date=val_date).filter(
@@ -612,7 +613,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
612
613
 
613
614
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
614
615
  self.estimate_net_asset_values(val_date)
615
- self.evaluate_rebalancing(val_date)
616
+ if evaluate_rebalancer:
617
+ self.evaluate_rebalancing(val_date)
616
618
 
617
619
  self.updated_at = timezone.now()
618
620
  self.save()
@@ -716,17 +718,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
716
718
  Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
717
719
  """
718
720
  analytic_portfolio = self.get_analytic_portfolio(start_date)
721
+ rebalancer = getattr(self, "automatic_rebalancer", None)
719
722
  initial_assets = analytic_portfolio.assets
720
723
  positions = []
721
724
  next_trade_proposal = None
722
725
  rebalancing_date = None
726
+ logger.info(f"compute next weights in batch for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
723
727
  returns, prices = self.get_returns(initial_assets, (start_date - BDay(3)).date(), end_date, ffill_returns=True)
724
728
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
725
729
  to_date = to_date_ts.date()
726
- if rebalancer := getattr(self, "automatic_rebalancer", None):
727
- if rebalancer.is_valid(to_date):
728
- rebalancing_date = to_date
729
- break
730
+ logger.info(f"Processing {to_date:%Y-%m-%d}")
731
+ if rebalancer and rebalancer.is_valid(to_date):
732
+ rebalancing_date = to_date
733
+ break
730
734
  # with suppress(IndexError):
731
735
  last_returns = returns.loc[[to_date_ts], :]
732
736
  next_weights = analytic_portfolio.get_next_weights(last_returns.iloc[-1, :].T)
@@ -737,7 +741,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
737
741
  )
738
742
  analytic_portfolio = AnalyticPortfolio(X=last_returns, weights=next_weights)
739
743
 
740
- self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False)
744
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False)
741
745
  if rebalancing_date:
742
746
  next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
743
747
 
@@ -759,7 +763,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
759
763
  # function to handle the position modification after instantiation
760
764
  if from_is_active and not to_is_active:
761
765
  asset.weighting = Decimal(0.0)
762
- asset.initial_shares = AssetPosition.objects.filter(
766
+ asset.initial_shares = AssetPosition.unannotated_objects.filter(
763
767
  date=from_date, underlying_quote=asset.underlying_quote, portfolio=self
764
768
  ).aggregate(sum_shares=Sum("initial_shares"))["sum_shares"]
765
769
  return asset
@@ -769,6 +773,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
769
773
  if (
770
774
  self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
771
775
  ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
776
+ logger.info(f"computing next weight for {self} from {from_date:%Y-%m-%d} to {to_date:%Y-%m-%d}")
772
777
  analytic_portfolio = self.get_analytic_portfolio(from_date)
773
778
  returns, prices = self.get_returns(analytic_portfolio.assets, (from_date - BDay(3)).date(), to_date)
774
779
  if not returns.empty:
@@ -929,7 +934,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
929
934
  update_dates.add(position.date)
930
935
  self.assets.filter(date__in=update_dates, is_estimated=True).delete()
931
936
  leftover_positions = self.assets.filter(date__in=update_dates).all()
932
- objs = AssetPosition.objects.bulk_create(
937
+ objs = AssetPosition.unannotated_objects.bulk_create(
933
938
  positions,
934
939
  update_fields=[
935
940
  "weighting",
@@ -1186,16 +1191,9 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
1186
1191
  previous_date := portfolio.get_latest_asset_position_date(val_date - BDay(1), with_estimated=True)
1187
1192
  ) and portfolio.assets.filter(date=val_date).exists():
1188
1193
  analytic_portfolio = portfolio.get_analytic_portfolio(val_date, with_previous_weights=True)
1189
- with suppress(InstrumentPrice.DoesNotExist, IndexError):
1190
- if not instrument.prices.filter(date__lte=previous_date).exists():
1191
- previous_net_asset_value = instrument.issue_price
1192
- else:
1193
- previous_net_asset_value = (
1194
- InstrumentPrice.objects.filter_only_valid_prices()
1195
- .get(instrument=instrument, date=previous_date)
1196
- .net_value
1197
- )
1198
- return analytic_portfolio.get_estimate_net_value(float(previous_net_asset_value))
1194
+ with suppress(IndexError):
1195
+ if last_price := instrument.get_latest_price(previous_date):
1196
+ return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1199
1197
 
1200
1198
 
1201
1199
  @receiver(post_save, sender="wbportfolio.Product")
@@ -1240,10 +1238,10 @@ def batch_recompute_lookthrough_as_task(portfolio_id: int, start: date, end: dat
1240
1238
  @receiver(investable_universe_updated, sender="wbfdm.Instrument")
1241
1239
  def update_portfolio_after_investable_universe(*args, end_date: date | None = None, **kwargs):
1242
1240
  if not end_date:
1243
- end_date = (date.today() - BDay(1)).date()
1241
+ end_date = date.today()
1242
+ end_date = (end_date + timedelta(days=1) - BDay(1)).date() # shift in case of business day
1244
1243
  from_date = (end_date - BDay(1)).date()
1245
1244
  for portfolio in Portfolio.tracked_objects.filter(is_lookthrough=False).to_dependency_iterator(from_date):
1246
- logger.info(f"computing next weight for {portfolio} from {from_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
1247
1245
  try:
1248
1246
  portfolio.propagate_or_update_assets(from_date, end_date)
1249
1247
  except Exception as e:
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from datetime import date
2
3
 
3
4
  from dateutil import rrule
@@ -18,6 +19,8 @@ from wbportfolio.models.transactions.trade_proposals import TradeProposal
18
19
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
19
20
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
20
21
 
22
+ logger = logging.getLogger("pms")
23
+
21
24
 
22
25
  class RebalancingModel(models.Model):
23
26
  name = models.CharField(max_length=64, verbose_name="Name")
@@ -44,7 +47,7 @@ class RebalancingModel(models.Model):
44
47
  ) -> PortfolioDTO:
45
48
  model = self.model_class(portfolio, trade_date, last_effective_date, **kwargs)
46
49
  if not model.is_valid():
47
- raise ValidationError("Rebalacing cannot applied for these parameters")
50
+ raise ValidationError(model.validation_errors)
48
51
  return model.get_target_portfolio()
49
52
 
50
53
  @classmethod
@@ -74,7 +77,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
74
77
  activation_date = models.DateField(verbose_name="Activation Date")
75
78
  frequency = models.CharField(
76
79
  default="RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1",
77
- max_length=56,
80
+ max_length=256,
78
81
  verbose_name=_("Evaluation Frequency"),
79
82
  help_text=_("The Evaluation Frequency in RRULE format"),
80
83
  )
@@ -113,6 +116,9 @@ class Rebalancer(ComplexToStringMixin, models.Model):
113
116
  "rebalancing_model": self.rebalancing_model,
114
117
  },
115
118
  )
119
+ logger.info(
120
+ f"Getting target portfolio ({self.portfolio}) for rebalancing model {self.rebalancing_model} for trade date {trade_date:%Y-%m-%d}"
121
+ )
116
122
  if trade_proposal.rebalancing_model == self.rebalancing_model:
117
123
  trade_proposal.status = TradeProposal.Status.DRAFT
118
124
  try:
@@ -195,8 +195,12 @@ class TradeProposal(RiskCheckMixin, WBModel):
195
195
 
196
196
  def _get_target_portfolio(self, **kwargs) -> PortfolioDTO:
197
197
  if self.rebalancing_model:
198
+ params = {}
199
+ if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
200
+ params.update(rebalancer.parameters)
201
+ params.update(kwargs)
198
202
  return self.rebalancing_model.get_target_portfolio(
199
- self.portfolio, self.trade_date, self.last_effective_date, **kwargs
203
+ self.portfolio, self.trade_date, self.last_effective_date, **params
200
204
  )
201
205
  # Return the current portfolio by default
202
206
  return self.portfolio._build_dto(self.last_effective_date)
@@ -245,28 +249,31 @@ class TradeProposal(RiskCheckMixin, WBModel):
245
249
  trade.save()
246
250
 
247
251
  def replay(self):
248
- trade_proposal = self
249
- while trade_proposal and trade_proposal.status == TradeProposal.Status.APPROVED:
250
- logger.info(f"Replaying trade proposal {self}")
251
- trade_proposal.portfolio.assets.filter(
252
- date=trade_proposal.trade_date
252
+ last_trade_proposal = self
253
+ while last_trade_proposal and last_trade_proposal.status == TradeProposal.Status.APPROVED:
254
+ logger.info(f"Replaying trade proposal {last_trade_proposal}")
255
+ last_trade_proposal.portfolio.assets.filter(
256
+ date=last_trade_proposal.trade_date
253
257
  ).delete() # we delete the existing position and we reapply the trade proposal
254
- if not trade_proposal.portfolio.assets.filter(date=trade_proposal.trade_date).exists():
255
- if trade_proposal.status == TradeProposal.Status.APPROVED:
256
- trade_proposal.revert()
257
- if trade_proposal.status == TradeProposal.Status.DRAFT:
258
- trade_proposal.submit()
259
- if trade_proposal.status == TradeProposal.Status.SUBMIT:
260
- trade_proposal.approve()
261
- trade_proposal.save()
262
- next_trade_proposal = trade_proposal.next_trade_proposal
258
+ if not last_trade_proposal.portfolio.assets.filter(date=last_trade_proposal.trade_date).exists():
259
+ if last_trade_proposal.status == TradeProposal.Status.APPROVED:
260
+ logger.info("Reverting trade proposal ...")
261
+ last_trade_proposal.revert()
262
+ if last_trade_proposal.status == TradeProposal.Status.DRAFT:
263
+ logger.info("Submitting trade proposal ...")
264
+ last_trade_proposal.submit()
265
+ if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
266
+ logger.info("Approving trade proposal ...")
267
+ last_trade_proposal.approve()
268
+ last_trade_proposal.save()
269
+ next_trade_proposal = last_trade_proposal.next_trade_proposal
263
270
  next_trade_date = (
264
271
  next_trade_proposal.trade_date - timedelta(days=1) if next_trade_proposal else date.today()
265
272
  )
266
- overriding_trade_proposal = trade_proposal.portfolio.batch_portfolio(
267
- trade_proposal.trade_date, next_trade_date
273
+ overriding_trade_proposal = last_trade_proposal.portfolio.batch_portfolio(
274
+ last_trade_proposal.trade_date, next_trade_date
268
275
  )
269
- trade_proposal = overriding_trade_proposal or next_trade_proposal
276
+ last_trade_proposal = overriding_trade_proposal or next_trade_proposal
270
277
 
271
278
  def estimate_shares(self, trade: Trade) -> Decimal | None:
272
279
  if not self.portfolio.only_weighting and (quote := trade.underlying_quote_price):
@@ -42,7 +42,7 @@ class TradeQueryset(OrderedModelQuerySet):
42
42
  def annotate_base_info(self):
43
43
  return self.annotate(
44
44
  last_effective_date=Subquery(
45
- AssetPosition.objects.filter(
45
+ AssetPosition.unannotated_objects.filter(
46
46
  date__lte=OuterRef("value_date"),
47
47
  portfolio=OuterRef("portfolio"),
48
48
  )
@@ -51,7 +51,7 @@ class TradeQueryset(OrderedModelQuerySet):
51
51
  ),
52
52
  effective_weight=Coalesce(
53
53
  Subquery(
54
- AssetPosition.objects.filter(
54
+ AssetPosition.unannotated_objects.filter(
55
55
  underlying_quote=OuterRef("underlying_instrument"),
56
56
  date=OuterRef("last_effective_date"),
57
57
  portfolio=OuterRef("portfolio"),
@@ -389,7 +389,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
389
389
  )
390
390
  def revert(self, to_date=None, **kwargs):
391
391
  with suppress(AssetPosition.DoesNotExist):
392
- asset = AssetPosition.objects.get(
392
+ asset = AssetPosition.unannotated_objects.get(
393
393
  underlying_quote=self.underlying_instrument,
394
394
  portfolio=self.portfolio,
395
395
  date=self.transaction_date,
@@ -412,7 +412,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
412
412
  if hasattr(self, "last_effective_date"):
413
413
  return self.last_effective_date
414
414
  elif (
415
- assets := AssetPosition.objects.filter(
415
+ assets := AssetPosition.unannotated_objects.filter(
416
416
  underlying_quote=self.underlying_instrument,
417
417
  date__lt=self.transaction_date,
418
418
  portfolio=self.portfolio,
@@ -426,7 +426,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
426
426
  return getattr(
427
427
  self,
428
428
  "effective_weight",
429
- AssetPosition.objects.filter(
429
+ AssetPosition.unannotated_objects.filter(
430
430
  underlying_quote=self.underlying_instrument,
431
431
  date=self._last_effective_date,
432
432
  portfolio=self.portfolio,
@@ -4,6 +4,10 @@ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
4
4
 
5
5
 
6
6
  class AbstractRebalancingModel:
7
+ @property
8
+ def validation_errors(self) -> str:
9
+ return getattr(self, "_validation_errors", "Rebalacing cannot applied for these parameters")
10
+
7
11
  def __init__(self, portfolio, trade_date: date, last_effective_date: date, **kwargs):
8
12
  self.portfolio = portfolio
9
13
  self.trade_date = trade_date
@@ -3,7 +3,13 @@ from decimal import Decimal
3
3
  import pandas as pd
4
4
  from django.db.models import Q, QuerySet
5
5
  from wbfdm.enums import MarketData
6
- from wbfdm.models import Classification, Instrument, InstrumentClassificationThroughModel
6
+ from wbfdm.models import (
7
+ Classification,
8
+ Exchange,
9
+ Instrument,
10
+ InstrumentClassificationThroughModel,
11
+ InstrumentListThroughModel,
12
+ )
7
13
 
8
14
  from wbportfolio.pms.typing import Portfolio, Position
9
15
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
@@ -17,8 +23,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
17
23
  def __init__(self, *args, **kwargs):
18
24
  super().__init__(*args, **kwargs)
19
25
  instruments = self._get_instruments(**kwargs)
20
- self.market_cap_df = pd.Series()
21
- df = pd.DataFrame(
26
+ self.market_cap_df = pd.DataFrame(
22
27
  instruments.dl.market_data(
23
28
  values=[MarketData.MARKET_CAPITALIZATION],
24
29
  from_date=self.last_effective_date,
@@ -26,17 +31,22 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
26
31
  target_currency=self.TARGET_CURRENCY,
27
32
  )
28
33
  )
34
+ self.exchange_df = pd.DataFrame(
35
+ instruments.values_list("id", "exchange"), columns=["id", "exchange"]
36
+ ).set_index("id")
29
37
  try:
30
- df = df[["valuation_date", "market_capitalization", "instrument_id"]].pivot_table(
31
- index="valuation_date", columns="instrument_id", values="market_capitalization"
32
- )
33
- df = df.ffill()
34
- self.market_cap_df = df.iloc[-1, :].transpose()
38
+ self.market_cap_df = self.market_cap_df[
39
+ ["valuation_date", "market_capitalization", "instrument_id"]
40
+ ].pivot_table(index="valuation_date", columns="instrument_id", values="market_capitalization")
41
+ # self.market_cap_df = df.iloc[-1, :].transpose()
35
42
  except (IndexError, KeyError):
36
- self.market_cap_df = pd.Series()
43
+ self.market_cap_df = pd.DataFrame()
37
44
 
38
45
  def _get_instruments(
39
- self, classification_ids: list[int] | None = None, instrument_ids: list[int] | None = None, **kwargs
46
+ self,
47
+ classification_ids: list[int] | None = None,
48
+ instrument_ids: list[int] | None = None,
49
+ instrument_list_id: int | None = None,
40
50
  ) -> QuerySet[Instrument]:
41
51
  """
42
52
  Use the provided kwargs to return a list of instruments as universe.
@@ -44,34 +54,62 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
44
54
  - Or directly from a static list of instrument ids
45
55
  - fallback to the last effective portfolio underlying instruments list
46
56
  """
57
+ if not instrument_ids:
58
+ instrument_ids = []
47
59
  if classification_ids:
48
60
  classifications = set()
49
61
  for classification in Classification.objects.filter(id__in=classification_ids):
50
62
  for children in classification.get_descendants(include_self=True):
51
63
  classifications.add(children)
52
- instrument_ids = list(
53
- InstrumentClassificationThroughModel.objects.filter(classification__in=classifications).values_list(
54
- "id", flat=True
64
+ instrument_ids.extend(
65
+ list(
66
+ InstrumentClassificationThroughModel.objects.filter(
67
+ classification__in=classifications
68
+ ).values_list("id", flat=True)
69
+ )
70
+ )
71
+ if instrument_list_id:
72
+ instrument_ids.extend(
73
+ list(
74
+ InstrumentListThroughModel.objects.filter(instrument_list_id=instrument_list_id).values_list(
75
+ "instrument", flat=True
76
+ )
55
77
  )
56
78
  )
57
- elif not instrument_ids:
79
+
80
+ if not instrument_ids:
58
81
  instrument_ids = list(
59
82
  self.portfolio.assets.filter(date=self.last_effective_date).values_list(
60
83
  "underlying_instrument", flat=True
61
84
  )
62
85
  )
86
+
63
87
  return Instrument.objects.filter(id__in=instrument_ids).filter(
64
- Q(delisted_date__isnull=True) | Q(delisted_date__gt=self.trade_date)
88
+ (Q(delisted_date__isnull=True) | Q(delisted_date__gt=self.trade_date))
65
89
  )
66
90
 
67
91
  def is_valid(self) -> bool:
68
- return (
69
- not self.market_cap_df.empty and not self.market_cap_df.isnull().any()
70
- ) # if we are missing any market cap for not-delisted instrument, we consider the rebalancing not valid
92
+ if not self.market_cap_df.empty:
93
+ trade_date_mktp_cap = (
94
+ self.market_cap_df.loc[self.trade_date, :].transpose().rename("market_capitalization")
95
+ )
96
+ df = pd.concat(
97
+ [trade_date_mktp_cap, self.exchange_df], axis=1
98
+ ) # if we are missing any market cap for not-delisted instrument, we consider the rebalancing not valid
99
+ df = df.groupby("exchange", dropna=False)["market_capitalization"].any()
100
+ missing_exchanges = Exchange.objects.filter(id__in=df[~df].index.to_list())
101
+ if missing_exchanges.exists():
102
+ setattr(
103
+ self,
104
+ "_validation_errors",
105
+ f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
106
+ )
107
+ return df.all()
108
+ return False
71
109
 
72
110
  def get_target_portfolio(self) -> Portfolio:
73
111
  positions = []
74
- market_cap_df = self.market_cap_df
112
+ market_cap_df = self.market_cap_df.ffill().loc[self.trade_date, :].transpose()
75
113
  total_market_cap = market_cap_df.sum()
76
114
 
77
115
  for underlying_instrument, market_cap in market_cap_df.to_dict().items():
@@ -1,5 +1,4 @@
1
- from contextlib import suppress
2
-
1
+ from django.contrib.messages import warning
3
2
  from django.core.exceptions import ValidationError
4
3
  from rest_framework.reverse import reverse
5
4
  from wbcore import serializers as wb_serializers
@@ -43,10 +42,11 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
43
42
  )
44
43
  ):
45
44
  target_portfolio_dto = target_portfolio._build_dto(last_effective_date)
46
- with suppress(
47
- ValidationError
48
- ): # we ignore validation error at this point as the trade are automatically create for convenience
45
+ try:
49
46
  obj.reset_trades(target_portfolio=target_portfolio_dto)
47
+ except ValidationError as e:
48
+ if request := self.context.get("request"):
49
+ warning(request, str(e))
50
50
  return obj
51
51
 
52
52
  @wb_serializers.register_only_instance_resource()
@@ -1,6 +1,5 @@
1
1
  from decimal import Decimal
2
2
 
3
- import numpy as np
4
3
  import pytest
5
4
  from pandas._libs.tslibs.offsets import BDay
6
5
  from wbfdm.models import InstrumentPrice
@@ -145,13 +144,14 @@ class TestMarketCapitalizationRebalancing:
145
144
  return MarketCapitalizationRebalancing(portfolio, weekday, last_effective_date, instrument_ids=[i1.id, i2.id])
146
145
 
147
146
  def test_is_valid(self, portfolio, weekday, model, instrument_factory, instrument_price_factory):
148
- assert model.is_valid()
149
- model.market_cap_df.iloc[0] = np.nan
150
147
  assert not model.is_valid()
148
+ i2 = model.market_cap_df.columns[1]
149
+ model.market_cap_df.loc[weekday, i2] = 1000 # some value
150
+ assert model.is_valid()
151
151
 
152
152
  def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
153
- i1 = model.market_cap_df.index[0]
154
- i2 = model.market_cap_df.index[1]
153
+ i1 = model.market_cap_df.columns[0]
154
+ i2 = model.market_cap_df.columns[1]
155
155
  mkt12 = InstrumentPrice.objects.get(instrument_id=i1, date=model.trade_date).market_capitalization
156
156
  mkt21 = InstrumentPrice.objects.get(instrument_id=i2, date=model.last_effective_date).market_capitalization
157
157
 
@@ -362,6 +362,7 @@ class CustomerDistributionInstrumentChartViewSet(UserPortfolioRequestPermissionM
362
362
  class TradeTradeProposalModelViewSet(
363
363
  UserPortfolioRequestPermissionMixin, InternalUserPermissionMixin, OrderableMixin, viewsets.ModelViewSet
364
364
  ):
365
+ IMPORT_ALLOWED = True
365
366
  ordering = (
366
367
  "trade_proposal",
367
368
  "order",
@@ -381,6 +382,14 @@ class TradeTradeProposalModelViewSet(
381
382
  def trade_proposal(self):
382
383
  return get_object_or_404(TradeProposal, pk=self.kwargs["trade_proposal_id"])
383
384
 
385
+ def has_import_permission(self, request) -> bool: # allow import only on draft trade proposal
386
+ return super().has_import_permission(request) and self.trade_proposal.status == TradeProposal.Status.DRAFT
387
+
388
+ def get_import_resource_kwargs(self):
389
+ resource_kwargs = super().get_import_resource_kwargs()
390
+ resource_kwargs["columns_mapping"] = {"underlying_instrument": "underlying_instrument__isin"}
391
+ return resource_kwargs
392
+
384
393
  def get_resource_class(self):
385
394
  return TradeProposalTradeResource
386
395
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbportfolio
3
- Version: 1.46.1
3
+ Version: 1.46.3
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  License-File: LICENSE
6
6
  Requires-Dist: cryptography==3.4.*
@@ -1,5 +1,5 @@
1
1
  wbportfolio/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
- wbportfolio/apps.py,sha256=Oa_0SF-vaNEii5e27xw6kZ-UsqlE8XHqzuhFP_KG8SQ,97
2
+ wbportfolio/apps.py,sha256=tybcoLFiw5tLdGHYV68X96n6jNZqx4BYx7Ao8mPflH8,749
3
3
  wbportfolio/dynamic_preferences_registry.py,sha256=iPGAiCScrIADX2NA8oChpQquAmTxVLO6O6ty_1n5dFg,2030
4
4
  wbportfolio/permissions.py,sha256=F147DXfitbw6IdMQGEFfymCJkiG5YGkWKsLdVVliPyw,320
5
5
  wbportfolio/preferences.py,sha256=yS3a7wmFdFTLJwus2dmg2oHbSSUL2lXNzHCVKGgUKGY,246
@@ -110,12 +110,12 @@ wbportfolio/import_export/backends/wbfdm/dividend.py,sha256=iAQXnYPXmtG_Jrc8THAJ
110
110
  wbportfolio/import_export/backends/wbfdm/mixin.py,sha256=JNtjgqGLson1nu_Chqb8MWyuiF3Ws8ox2vapxIRBYKE,400
111
111
  wbportfolio/import_export/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
112
112
  wbportfolio/import_export/handlers/adjustment.py,sha256=6bdTIYFmc8_HFxcdwtnYwglMyCfAD8XrTIrEb2zWY0g,1757
113
- wbportfolio/import_export/handlers/asset_position.py,sha256=z74ew0XBEemNLELsuwKeLXOMDXeeWyTWium6LEIw7CA,8645
113
+ wbportfolio/import_export/handlers/asset_position.py,sha256=5wFnHcbq_zGp9rBUec_JEpzjCA0_v17VrV9F8Ps1ETs,8645
114
114
  wbportfolio/import_export/handlers/dividend.py,sha256=tftdVdAzNpKSSvouOtvJfzWL362HUPIC94F6Noha8CE,3998
115
115
  wbportfolio/import_export/handlers/fees.py,sha256=XYH752IkNGYhhhwatp8nYa1zG1-YZFDkYW15dyQgOIg,2824
116
116
  wbportfolio/import_export/handlers/portfolio_cash_flow.py,sha256=2ODaquuC83RfmNwmQ-8TdhiASObfIems_B1g0yqaYTs,2733
117
117
  wbportfolio/import_export/handlers/register.py,sha256=sYyXkE8b1DPZ5monxylZn0kjxLVdNYYZR-p61dwEoDM,2271
118
- wbportfolio/import_export/handlers/trade.py,sha256=LydAyFtqHPNfCF6j1yY9Md0xWf6NMGOE22-xr2UGq40,9975
118
+ wbportfolio/import_export/handlers/trade.py,sha256=LIzXNEN7-tRWCUBvisgy8eTDL85JWO5c7iN06yJA-TI,10395
119
119
  wbportfolio/import_export/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
120
120
  wbportfolio/import_export/parsers/default_mapping.py,sha256=KrO-X5CvQCeQoBYzFDxavoQGriyUSeI2QDx5ar_zo7A,1405
121
121
  wbportfolio/import_export/parsers/jpmorgan/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -190,7 +190,7 @@ wbportfolio/import_export/parsers/vontobel/valuation.py,sha256=iav8_xYpTJchmTa7K
190
190
  wbportfolio/import_export/parsers/vontobel/valuation_api.py,sha256=WLkZ5z-WqhFraNorWlOhIpSx1pQ2fnjdsLHwSTA7O2o,882
191
191
  wbportfolio/import_export/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
192
192
  wbportfolio/import_export/resources/assets.py,sha256=zjgHlQWpud41jHrKdRyqGUt1KUJQm9Z7pt0uVh4qBWQ,2234
193
- wbportfolio/import_export/resources/trades.py,sha256=5HC0YrE1fnc_dOMfkOEj7vsICv1JN_OEZiGryg2GboY,1125
193
+ wbportfolio/import_export/resources/trades.py,sha256=LC1SBFmoxT160s0__cYDV2i99uECVrR7OHN0vAJ2LPQ,2232
194
194
  wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql,sha256=BvMnLsVlXIqU5hhwLDbwZ6nBqWULIBDVcgLJ2B4sdS4,4440
195
195
  wbportfolio/kpi_handlers/nnm.py,sha256=hCn0oG0C-6dQ0G-6S4r31nAS633NZdlOT-ntZrzvXZI,7180
196
196
  wbportfolio/metric/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -239,13 +239,14 @@ wbportfolio/migrations/0070_remove_assetposition_unique_asset_position_and_more.
239
239
  wbportfolio/migrations/0071_alter_trade_options_alter_trade_order.py,sha256=QjAyQr1eSs2X73zL03uG_MjfcGZhSJV9YQ0UJ39FpVk,695
240
240
  wbportfolio/migrations/0072_trade_diff_shares.py,sha256=aTKa1SbIiwmlXaFtBg-ENrSxfM_cf3RPNQBQlk2VEZ0,635
241
241
  wbportfolio/migrations/0073_remove_product_price_computation_and_more.py,sha256=J4puisDFwnbnfv2VLWaiCQ7ost6PCOkin9qKVQoLIWM,18725
242
+ wbportfolio/migrations/0074_alter_rebalancer_frequency_and_more.py,sha256=o01rBj-ADgwCRtAai3e5z27alPGEzaiNxUqCwWm6peY,918
242
243
  wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
243
244
  wbportfolio/models/__init__.py,sha256=PDLJry5w1zE4N4arQh20_uFi2v7gy9QyavJ_rfGE21Q,882
244
245
  wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
245
246
  wbportfolio/models/asset.py,sha256=V-fSAF1bsfj_Td3VfdKyhCgcwB_xPS6vTI90Dh5iDx8,37502
246
247
  wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
247
248
  wbportfolio/models/indexes.py,sha256=iLYF2gzNzX4GLj_Nh3fybUcAQ1TslnT0wgQ6mN164QI,728
248
- wbportfolio/models/portfolio.py,sha256=-B--vg_vGsZjHtUsW1hrkmg8LoGeyBO_ilcE6BEE3e0,55459
249
+ wbportfolio/models/portfolio.py,sha256=r_qTDNTeUu9QiJ6g_g1rIp5yOuUOwinqadJtykb9KE8,55501
249
250
  wbportfolio/models/portfolio_cash_flow.py,sha256=2blPiXSw7dbhUVd-7LcxDBb4v0SheNOdvRK3MFYiChA,7273
250
251
  wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
251
252
  wbportfolio/models/portfolio_relationship.py,sha256=mMb18UMRWg9kx_9uIPkMktwORuXXLjKdgRPQQvB6fVE,5486
@@ -260,7 +261,7 @@ wbportfolio/models/graphs/portfolio.py,sha256=NwkehWvTcyTYrKO5ku3eNNaYLuBwuLdSbT
260
261
  wbportfolio/models/graphs/utils.py,sha256=1AMpEE9mDuUZ82XgN2irxjCW1-LmziROhKevEBo0mJE,2347
261
262
  wbportfolio/models/llm/wbcrm/analyze_relationship.py,sha256=_y2Myc-M2hXQDkRGXvzsM0ZNC31dmxSHHz5BKMtymww,2106
262
263
  wbportfolio/models/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
263
- wbportfolio/models/mixins/instruments.py,sha256=qxIXir762zgDzf5U21GyrlmbVH5I2IaIIgulpaZOHNo,6197
264
+ wbportfolio/models/mixins/instruments.py,sha256=IucFwxbSxyqLwDbaMYg_vKh1BPwGqo1VERpj0U-nS0Q,6728
264
265
  wbportfolio/models/mixins/liquidity_stress_test.py,sha256=whkzjtbOyl_ncNyaQBORb_Z_rDgcvfdTYPgqPolu7dA,58865
265
266
  wbportfolio/models/reconciliations/__init__.py,sha256=MXH5fZIPGDRBgJkO6wVu_NLRs8fkP1im7G6d-h36lQY,127
266
267
  wbportfolio/models/reconciliations/account_reconciliation_lines.py,sha256=QP6M7hMcyFbuXBa55Y-azui6Dl_WgbzMntEqWzQkbfM,7394
@@ -271,9 +272,9 @@ wbportfolio/models/transactions/claim.py,sha256=agdpGqxpO0FSzYDWV-Gv1tQY46k0LN9C
271
272
  wbportfolio/models/transactions/dividends.py,sha256=naL5xeDQfUBf5KyGt7y-tTcHL22nzZumT8DV6AaG8Bg,1064
272
273
  wbportfolio/models/transactions/expiry.py,sha256=vnNHdcC1hf2HP4rAbmoGgOfagBYKNFytqOwzOI0MlVI,144
273
274
  wbportfolio/models/transactions/fees.py,sha256=ffvqo8I4A0l5rLi00jJ6sGot0jmnkoxaNsbDzdPLwCg,5712
274
- wbportfolio/models/transactions/rebalancing.py,sha256=9EdElkaqPoH14FBSK9QOCLFzVpr4YFa3YrgCJV9XvaI,6396
275
- wbportfolio/models/transactions/trade_proposals.py,sha256=ZpMWOKXfWo85AW2cqp0X5aUGabGECB4W1bK4RaD5-QM,20311
276
- wbportfolio/models/transactions/trades.py,sha256=3HthfL0wgzPmwwXRVzA5yvm-UPiQco7HhN30ztsOjvI,27529
275
+ wbportfolio/models/transactions/rebalancing.py,sha256=vEYdYhog9jiPw_xQd_XQunNzcpHZ8Zk18kQhsN4HuhA,6596
276
+ wbportfolio/models/transactions/trade_proposals.py,sha256=tpxg6EJ8Z6sB7VJ39GVLH-sBVJ9_ypUOqaUE0T5Rvhk,20804
277
+ wbportfolio/models/transactions/trades.py,sha256=7IAC9eDx-ouKGn1RPR_Ggd5XjLgr3rE4xBn_ub0o1TE,27589
277
278
  wbportfolio/models/transactions/transactions.py,sha256=4THsE4xqdigZAwWKYfTNRLPJlkmAmsgE70Ribp9Lnrk,7127
278
279
  wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
279
280
  wbportfolio/pms/typing.py,sha256=lRWh9alcstZzwA04hFSPZfOFbCjaVPWtUpWnurnsh8c,6014
@@ -283,12 +284,12 @@ wbportfolio/pms/statistics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
283
284
  wbportfolio/pms/trading/__init__.py,sha256=R_yLKc54sCak8A1cW0O1Aszrcv5KV8mC_3h17Hr20e4,36
284
285
  wbportfolio/pms/trading/handler.py,sha256=Xpgo719S0jE1wUTTyGFpYccPEIg9GXghWEAdYawJbrk,7165
285
286
  wbportfolio/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
286
- wbportfolio/rebalancing/base.py,sha256=Z5IAG8zu6_RLkQE1AgIoROBUDXWza0myzKhKx_4ONwA,481
287
+ wbportfolio/rebalancing/base.py,sha256=NwTGZtBm1f35gj5Jp6iTyyFvDT1GSIztN990cKBvYzQ,637
287
288
  wbportfolio/rebalancing/decorators.py,sha256=JhQ2vkcIuGBTGvNmpkQerdw2-vLq-RAb0KAPjOrETkw,515
288
289
  wbportfolio/rebalancing/models/__init__.py,sha256=AQjG7Tu5vlmhqncVoYOjpBKU2UIvgo9FuP2_jD2w-UI,232
289
290
  wbportfolio/rebalancing/models/composite.py,sha256=XAjJqLRNsV-MuBKrat3THEfAWs6PXQNSO0g8k8MtBXo,1157
290
291
  wbportfolio/rebalancing/models/equally_weighted.py,sha256=U29MOHJMQMIg7Y7W_8t5K3nXjaznzt4ArIxQSiv0Xok,863
291
- wbportfolio/rebalancing/models/market_capitalization_weighted.py,sha256=AYVoLg_bf7s7DSXVjBuIOltFpkySVmv2lrDD-pKBH9I,3643
292
+ wbportfolio/rebalancing/models/market_capitalization_weighted.py,sha256=RskXZ41iSK9g4SSYJRNv0hX6z9Ol73nJY2N7A3IxCLw,5051
292
293
  wbportfolio/rebalancing/models/model_portfolio.py,sha256=XQdvs03-0M9YUnL4DidwZC4E6k-ANCNcZ--T_aaOXTQ,1233
293
294
  wbportfolio/reports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
294
295
  wbportfolio/reports/monthly_position_report.py,sha256=e7BzjDd6eseUOwLwQJXKvWErQ58YnCsznHU2VtR6izM,2981
@@ -338,7 +339,7 @@ wbportfolio/serializers/transactions/claim.py,sha256=kC4E2RZRrpd9i8tGfoiV-gpWDk3
338
339
  wbportfolio/serializers/transactions/dividends.py,sha256=EULwKDumHBv4r2HsdEGZMZGFaye4dRUNNyXg6-wZXzc,520
339
340
  wbportfolio/serializers/transactions/expiry.py,sha256=K3XOSbCyef-xRzOjCr4Qg_YFJ_JuuiJ9u6tDS86l0hg,477
340
341
  wbportfolio/serializers/transactions/fees.py,sha256=uPmSWuCeoV2bwVS6RmEz3a0VRBWJHIQr0WhklYc1UAI,1068
341
- wbportfolio/serializers/transactions/trade_proposals.py,sha256=W1fEkis3A-KcCrlBrjVB7F6xJ8xnvSDmAxb7y7Sw7JA,3417
342
+ wbportfolio/serializers/transactions/trade_proposals.py,sha256=h8Ub69YPEhds1fC0dcbvXmSmpszor0WWxsodUeJmPVY,3414
342
343
  wbportfolio/serializers/transactions/trades.py,sha256=pONV5NSqrXUnoTEoAxovnnQqu37cZGuB33TYvIOK3rE,10009
343
344
  wbportfolio/serializers/transactions/transactions.py,sha256=O137zeCndK-nxIWSRLEj7bXbBZDGa4d6qK6pJIIYK3g,4170
344
345
  wbportfolio/static/wbportfolio/css/macro_review.css,sha256=FAVVO8nModxwPXcTKpcfzVxBGPZGJVK1Xn-0dkSfGyc,233
@@ -384,7 +385,7 @@ wbportfolio/tests/models/transactions/test_trades.py,sha256=z0CCZjB648ECDSEdwmzq
384
385
  wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
385
386
  wbportfolio/tests/pms/test_analytics.py,sha256=FrvVsV_uUiTgmRUfsaB-_sGzY30CqknbOY2DvmwR_70,1141
386
387
  wbportfolio/tests/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
387
- wbportfolio/tests/rebalancing/test_models.py,sha256=Zph5NFWeIJVFxv06F6CBrJC0xPr3TB5ns0EdgzKdh8U,7610
388
+ wbportfolio/tests/rebalancing/test_models.py,sha256=00VGrz_UZtYSqpTg2J0XCt-zInOAvYSNezad8KKxNnw,7660
388
389
  wbportfolio/tests/serializers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
389
390
  wbportfolio/tests/serializers/test_claims.py,sha256=vQrg73xQXRFEgvx3KI9ivFre_wpBFzdO0p0J13PkvdY,582
390
391
  wbportfolio/tests/viewsets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -514,9 +515,9 @@ wbportfolio/viewsets/transactions/fees.py,sha256=7VUXIogmRrXCz_D9tvDiiTae0t5j09W
514
515
  wbportfolio/viewsets/transactions/mixins.py,sha256=WipvJoi5hylkpD0y9VATe30WAcwIHUIroVkK10FYw7k,636
515
516
  wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQVTN2MH1kG_yCeVkmyK8k,1263
516
517
  wbportfolio/viewsets/transactions/trade_proposals.py,sha256=fYTvvRk7k5xsBzbIgJvU4I4OrllF0VkhlrekD4GVgDk,4296
517
- wbportfolio/viewsets/transactions/trades.py,sha256=6iTIM5g7TUlRtiCjIG4EdYvyfaoB6K3UC2WRX9BD9Jg,15850
518
+ wbportfolio/viewsets/transactions/trades.py,sha256=wdtEWN1V5wsmesR3mRxPmTJUIAmDmqaNsfIhOB57kqY,16330
518
519
  wbportfolio/viewsets/transactions/transactions.py,sha256=ixDp-nsNA8t_A06rBCT19hOMJHy0iRmdz1XKdV1OwAs,4450
519
- wbportfolio-1.46.1.dist-info/METADATA,sha256=MBqT0rgbHIWQspGtldYVWSruQOouRdQDxcV4U9dunWk,734
520
- wbportfolio-1.46.1.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
521
- wbportfolio-1.46.1.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
522
- wbportfolio-1.46.1.dist-info/RECORD,,
520
+ wbportfolio-1.46.3.dist-info/METADATA,sha256=oDO6H1uvqNe5ZlHMqFbR4LPQ2x7KfULE_ePXVkPF4pg,734
521
+ wbportfolio-1.46.3.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
522
+ wbportfolio-1.46.3.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
523
+ wbportfolio-1.46.3.dist-info/RECORD,,