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

Files changed (79) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -68,6 +68,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
68
68
  help_text="The Ex-Post daily return",
69
69
  )
70
70
 
71
+ execution_confirmed = models.BooleanField(default=False, verbose_name="Execution Confirmed")
72
+ execution_comment = models.TextField(default="", blank=True, verbose_name="Execution Comment")
73
+
71
74
  def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
72
75
  warnings = []
73
76
 
@@ -93,7 +96,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
93
96
  if not self.price:
94
97
  warnings.append(f"No price for {self.underlying_instrument.computed_str}")
95
98
  if (
96
- not self.underlying_instrument.is_cash and self._target_weight < -1e-8
99
+ not self.underlying_instrument.is_cash
100
+ and not self.underlying_instrument.is_cash_equivalent
101
+ and self._target_weight < -1e-8
97
102
  ): # any value below -1e8 will be considered zero
98
103
  warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
99
104
  self.desired_target_weight = self._target_weight
@@ -185,27 +190,32 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
185
190
  ]
186
191
  # notification_email_template = "portfolio/email/trade_notification.html"
187
192
 
188
- def save(self, *args, **kwargs):
193
+ def pre_save(self):
189
194
  self.portfolio = self.order_proposal.portfolio
190
195
  self.value_date = self.order_proposal.trade_date
191
196
 
192
- if abs(self.weighting) < 10e-6:
193
- self.weighting = Decimal("0")
194
- if not self.underlying_instrument.is_investable_universe:
195
- self.underlying_instrument.is_investable_universe = True
196
- self.underlying_instrument.save()
197
-
198
197
  if not self.price:
199
198
  # we try to get the price if not provided directly from the underlying instrument
200
- self.price = self.get_price()
201
- if self.portfolio.only_weighting:
199
+ if self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent:
200
+ self.price = Decimal("1")
201
+ else:
202
+ self.price = self.get_price()
203
+ if not self.portfolio.only_weighting:
202
204
  estimated_shares = self.order_proposal.get_estimated_shares(
203
205
  self.weighting, self.underlying_instrument, self.price
204
206
  )
205
207
  if estimated_shares:
206
208
  self.shares = estimated_shares
209
+ super().pre_save()
210
+
211
+ def save(self, *args, **kwargs):
212
+ self.pre_save()
213
+ if not self.underlying_instrument.is_investable_universe:
214
+ self.underlying_instrument.is_investable_universe = True
215
+ self.underlying_instrument.save()
216
+
207
217
  if self.id:
208
- self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
218
+ self.set_type()
209
219
  super().save(*args, **kwargs)
210
220
 
211
221
  @classmethod
@@ -224,6 +234,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
224
234
  else:
225
235
  return Order.Type.SELL
226
236
 
237
+ def set_type(self):
238
+ self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
239
+
227
240
  def get_price(self) -> Decimal:
228
241
  try:
229
242
  return self.underlying_instrument.get_price(self.value_date)
@@ -0,0 +1,54 @@
1
+ # TBD: Should this stay a service or should we extend the order proposal model (fat model approach) ?
2
+ from contextlib import suppress
3
+ from typing import TYPE_CHECKING
4
+
5
+ from django.conf import settings
6
+ from django.core.exceptions import ObjectDoesNotExist
7
+
8
+ from wbportfolio.order_routing import ExecutionStatus
9
+
10
+ if TYPE_CHECKING:
11
+ from wbportfolio.models import OrderProposal
12
+
13
+
14
+ def _should_route_as_draft() -> bool:
15
+ """Determine whether orders should be routed as drafts."""
16
+ return getattr(settings, "DEBUG", True) or getattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
17
+
18
+
19
+ def _update_orders_with_confirmations(order_proposal, confirmations, rebalancing_comment):
20
+ """Update all orders in the proposal based on confirmed executions."""
21
+
22
+
23
+ def execute_orders(order_proposal: "OrderProposal") -> tuple[ExecutionStatus, str]:
24
+ """
25
+ Executes the prepared orders of an order proposal via its custodian adapter.
26
+ Updates execution statuses and handles routing errors gracefully.
27
+ """
28
+ orders = order_proposal.prepare_orders_for_execution()
29
+ as_draft = _should_route_as_draft()
30
+ adapter = order_proposal.custodian_adapter
31
+ order_confirmations, rebalancing_comment = adapter.submit_rebalancing(orders, as_draft=as_draft)
32
+ leftover_orders = order_proposal.orders.all()
33
+
34
+ for confirmed in order_confirmations:
35
+ with suppress(ObjectDoesNotExist):
36
+ order = leftover_orders.get(id=confirmed.id)
37
+ order.execution_confirmed = True
38
+ order.execution_comment = order.comment
39
+ order.save()
40
+ leftover_orders = leftover_orders.exclude(id=order.id)
41
+
42
+ # Orders without confirmation
43
+ leftover_orders.update(execution_confirmed=False, execution_comment="No confirmation received from the custodian")
44
+ return ExecutionStatus.IN_DRAFT if as_draft else ExecutionStatus.PENDING, rebalancing_comment
45
+
46
+
47
+ def get_execution_status(order_proposal: "OrderProposal") -> tuple[ExecutionStatus, str]:
48
+ adapter = order_proposal.custodian_adapter
49
+ return adapter.get_rebalance_status()
50
+
51
+
52
+ def cancel_rebalancing(order_proposal: "OrderProposal") -> bool:
53
+ adapter = order_proposal.custodian_adapter
54
+ return adapter.cancel_current_rebalancing()
@@ -2,7 +2,6 @@ import logging
2
2
  from contextlib import suppress
3
3
  from datetime import date, timedelta
4
4
  from decimal import Decimal
5
- from math import isclose
6
5
  from typing import TYPE_CHECKING, Any, Generator, Iterable
7
6
 
8
7
  import numpy as np
@@ -22,7 +21,7 @@ from wbcore.contrib.notifications.utils import create_notification_type
22
21
  from wbcore.models import WBModel
23
22
  from wbcore.utils.importlib import import_from_dotted_path
24
23
  from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
25
- from wbfdm.models import Instrument, InstrumentType
24
+ from wbfdm.models import Cash, Instrument, InstrumentType
26
25
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
27
26
  from wbfdm.signals import investable_universe_updated
28
27
 
@@ -39,6 +38,7 @@ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
39
38
  from wbportfolio.pms.typing import Position as PositionDTO
40
39
 
41
40
  from ..constants import EQUITY_TYPE_KEYS
41
+ from ..order_routing.adapters import BaseCustodianAdapter
42
42
  from . import ProductGroup
43
43
 
44
44
  logger = logging.getLogger("pms")
@@ -263,6 +263,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
263
263
  dependency_portfolio__is_composition=True,
264
264
  ).dependency_portfolio
265
265
 
266
+ @property
267
+ def is_model(self) -> bool:
268
+ return PortfolioPortfolioThroughModel.objects.filter(
269
+ type=PortfolioPortfolioThroughModel.Type.MODEL,
270
+ dependency_portfolio=self,
271
+ ).exists()
272
+
266
273
  @property
267
274
  def imported_assets(self):
268
275
  return self.assets.filter(is_estimated=False)
@@ -274,6 +281,30 @@ class Portfolio(DeleteToDisableMixin, WBModel):
274
281
  instruments.extend([i for i in Index.objects.filter(portfolios=self)])
275
282
  return instruments
276
283
 
284
+ @property
285
+ def cash_component(self) -> Cash:
286
+ return Cash.objects.get_or_create(
287
+ currency=self.currency, defaults={"is_cash": True, "name": self.currency.title}
288
+ )[0]
289
+
290
+ def get_authenticated_custodian_adapter(self, **kwargs) -> BaseCustodianAdapter | None:
291
+ supported_instruments_for_routing = list(
292
+ filter(lambda o: o.order_routing_custodian_adapter, self.pms_instruments)
293
+ )
294
+ if not supported_instruments_for_routing:
295
+ raise ValueError("No custodian adapter for this portfolio")
296
+
297
+ pms_instrument = supported_instruments_for_routing[
298
+ 0
299
+ ] # for simplicity we support only one instrument per portfolio that is allowed to support order routing
300
+ adapter = import_from_dotted_path(pms_instrument.order_routing_custodian_adapter)(
301
+ isin=pms_instrument.isin, identifier=pms_instrument.identifier, **kwargs
302
+ )
303
+ adapter.authenticate()
304
+ if not adapter.is_valid():
305
+ raise ValueError("This portfolio is not valid for rebalancing")
306
+ return adapter
307
+
277
308
  @property
278
309
  def can_be_rebalanced(self):
279
310
  return self.is_manageable and not self.is_lookthrough
@@ -337,13 +368,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
337
368
  if not weights:
338
369
  weights = self.get_weights(val_date)
339
370
  return_date = (val_date + BDay(1)).date()
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)
371
+ returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl).copy()
347
372
  if pd.Timestamp(return_date) not in returns.index:
348
373
  raise ValueError()
349
374
  returns = returns.loc[:return_date, :]
@@ -641,11 +666,29 @@ class Portfolio(DeleteToDisableMixin, WBModel):
641
666
  if automatic_rebalancer := getattr(self, "automatic_rebalancer", None):
642
667
  return automatic_rebalancer.get_next_rebalancing_date(start_date)
643
668
 
669
+ def fix_quantization(self, val_date: date):
670
+ assets = self.assets.filter(date=val_date)
671
+ total_weighting = assets.aggregate(s=Sum("weighting"))["s"]
672
+ if quantization_error := Decimal("1") - total_weighting:
673
+ cash = self.cash_component
674
+ try:
675
+ cash_pos = assets.get(underlying_quote=cash)
676
+ cash_pos.weighting += quantization_error
677
+ except AssetPosition.DoesNotExist:
678
+ cash_pos = AssetPosition(
679
+ portfolio=self,
680
+ underlying_quote=cash,
681
+ weighting=quantization_error,
682
+ initial_price=Decimal("1"),
683
+ date=val_date,
684
+ is_estimated=True,
685
+ )
686
+ cash_pos.save(create_underlying_quote_price_if_missing=True)
687
+
644
688
  def change_at_date(
645
689
  self,
646
690
  val_date: date,
647
- recompute_weighting: bool = False,
648
- force_recompute_weighting: bool = False,
691
+ fix_quantization: bool = False,
649
692
  evaluate_rebalancer: bool = True,
650
693
  changed_portfolio: AnalyticPortfolio | None = None,
651
694
  broadcast_changes_at_date: bool = True,
@@ -653,28 +696,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
653
696
  ):
654
697
  logger.info(f"change at date for {self} at {val_date}")
655
698
 
656
- if recompute_weighting:
657
- # We normalize weight across the portfolio for a given date
658
- qs = self.assets.filter(date=val_date).filter(
659
- Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
660
- )
661
- if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
662
- total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
663
- # We check if this actually necessary
664
- # (i.e. if the weight is already summed to 100%, it is already normalized)
665
- if (
666
- not total_weighting
667
- or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001)
668
- or force_recompute_weighting
669
- ):
670
- total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
671
- # TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
672
- for asset in qs:
673
- if total_value:
674
- asset.weighting = asset._total_value_fx_portfolio / total_value
675
- elif total_weighting:
676
- asset.weighting = asset.weighting / total_weighting
677
- asset.save()
699
+ if fix_quantization:
700
+ # We assume all ptf total weight is 100% but quantization error can occur. In that case, we create a cash component and add the weight there.
701
+ self.fix_quantization(val_date)
678
702
 
679
703
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
680
704
  self.estimate_net_asset_values(
@@ -693,8 +717,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
693
717
  if broadcast_changes_at_date:
694
718
  self.handle_controlling_portfolio_change_at_date(
695
719
  val_date,
696
- recompute_weighting=recompute_weighting,
697
- force_recompute_weighting=force_recompute_weighting,
720
+ fix_quantization=fix_quantization,
698
721
  changed_portfolio=changed_portfolio,
699
722
  **kwargs,
700
723
  )
@@ -712,9 +735,20 @@ class Portfolio(DeleteToDisableMixin, WBModel):
712
735
  ):
713
736
  rel.portfolio.evaluate_rebalancing(val_date)
714
737
  for dependent_portfolio in self.get_child_portfolios(val_date):
715
- dependent_portfolio.change_at_date(val_date, **kwargs)
738
+ # dependent_portfolio.change_at_date(val_date, **kwargs)
716
739
  dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date, **kwargs)
717
740
 
741
+ def get_model_portfolio_relationships(
742
+ self, val_date: date
743
+ ) -> Generator[PortfolioPortfolioThroughModel, None, None]:
744
+ for rel in PortfolioPortfolioThroughModel.objects.filter(
745
+ dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
746
+ ):
747
+ if rel.portfolio.is_active_at_date(val_date):
748
+ yield rel
749
+ for dependent_portfolio in self.get_child_portfolios(val_date):
750
+ yield from dependent_portfolio.get_model_portfolio_relationships(val_date)
751
+
718
752
  def evaluate_rebalancing(self, val_date: date):
719
753
  if hasattr(self, "automatic_rebalancer"):
720
754
  # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a order proposal automatically
@@ -770,7 +804,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
770
804
  if not weights:
771
805
  previous_date = self.assets.filter(date__lte=start_date).latest("date").date
772
806
  _, weights = next(self.drift_weights(previous_date, start_date))
773
-
774
807
  last_order_proposal = None
775
808
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
776
809
  to_date = to_date_ts.date()
@@ -785,10 +818,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
785
818
  break
786
819
  try:
787
820
  order_proposal = self.order_proposals.get(
788
- trade_date=to_date, rebalancing_model__isnull=True, status="APPROVED"
821
+ trade_date=to_date, rebalancing_model__isnull=True, status="APPLIED"
789
822
  )
790
823
  except ObjectDoesNotExist:
791
824
  if rebalancer and rebalancer.is_valid(to_date):
825
+ rebalancer.portfolio = self # ensure reference is the same to access cached returns
792
826
  effective_portfolio = PortfolioDTO(
793
827
  positions=[
794
828
  PositionDTO(
@@ -841,12 +875,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
841
875
  for pos_date, weights in self.drift_weights(from_date, to_date):
842
876
  self.builder.add((pos_date, weights))
843
877
  self.builder.bulk_create_positions(delete_leftovers=True)
844
- self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
878
+ self.builder.schedule_change_at_dates()
845
879
  self.builder.schedule_metric_computation()
846
880
 
847
881
  def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
848
882
  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"):
883
+ for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date):
850
884
  instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
851
885
  self.builder.load_returns(
852
886
  set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
@@ -935,9 +969,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
935
969
  )
936
970
  self.builder.add(
937
971
  list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
972
+ infer_underlying_quote_price=True,
938
973
  )
939
974
  self.builder.bulk_create_positions(delete_leftovers=True)
940
- self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
975
+ self.builder.schedule_change_at_dates()
941
976
  self.builder.schedule_metric_computation()
942
977
 
943
978
  def update_preferred_classification_per_instrument(self):
@@ -1134,6 +1169,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1134
1169
  index = Index.objects.create(name=self.name, currency=self.currency)
1135
1170
  index.portfolios.all().delete()
1136
1171
  InstrumentPortfolioThroughModel.objects.update_or_create(instrument=index, defaults={"portfolio": self})
1172
+ return index
1137
1173
 
1138
1174
  @classmethod
1139
1175
  def create_model_portfolio(cls, name: str, currency: Currency, with_index: bool = True):
@@ -4,6 +4,7 @@ from decimal import Decimal
4
4
 
5
5
  from celery import shared_task
6
6
  from django.contrib import admin
7
+ from django.contrib.contenttypes.fields import GenericRelation
7
8
  from django.contrib.postgres.constraints import ExclusionConstraint
8
9
  from django.contrib.postgres.fields import DateRangeField, RangeOperators
9
10
  from django.db import models
@@ -33,6 +34,7 @@ from wbcore.utils.enum import ChoiceEnum
33
34
  from wbcrm.models.accounts import Account
34
35
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
35
36
  from wbfdm.models.instruments.instruments import InstrumentManager, InstrumentType
37
+ from wbreport.models import Report
36
38
 
37
39
  from wbportfolio.models.portfolio_relationship import InstrumentPortfolioThroughModel
38
40
 
@@ -198,6 +200,8 @@ class FeeProductPercentage(models.Model):
198
200
 
199
201
 
200
202
  class Product(PMSInstrumentAbstractModel):
203
+ reports = GenericRelation(Report)
204
+
201
205
  share_price = models.PositiveIntegerField(
202
206
  default=100,
203
207
  verbose_name="Share Price",
@@ -344,6 +348,11 @@ class Product(PMSInstrumentAbstractModel):
344
348
 
345
349
  self.is_managed = True
346
350
 
351
+ def save(self, *args, **kwargs):
352
+ super().save(*args, **kwargs)
353
+ if self.delisted_date and self.delisted_date <= date.today():
354
+ self.reports.update(is_active=False)
355
+
347
356
  def get_title(self):
348
357
  if self.parent:
349
358
  return f"{self.parent.name} ({self.name})"
@@ -78,7 +78,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
78
78
  RebalancingModel, on_delete=models.PROTECT, related_name="rebalancers", verbose_name="Rebalancing Model"
79
79
  )
80
80
  parameters = models.JSONField(default=dict, verbose_name="Parameters", blank=True)
81
- approve_order_proposal_automatically = models.BooleanField(
81
+ apply_order_proposal_automatically = models.BooleanField(
82
82
  default=False, verbose_name="Apply Order Proposal Automatically"
83
83
  )
84
84
  activation_date = models.DateField(verbose_name="Activation Date")
@@ -108,10 +108,10 @@ class Rebalancer(ComplexToStringMixin, models.Model):
108
108
  def is_valid(self, trade_date: date) -> bool:
109
109
  if OrderProposal.objects.filter(
110
110
  portfolio=self.portfolio,
111
- status=OrderProposal.Status.APPROVED,
111
+ status=OrderProposal.Status.APPLIED,
112
112
  trade_date=trade_date,
113
113
  rebalancing_model__isnull=True,
114
- ).exists(): # if a already approved order proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")
114
+ ).exists(): # if a already applied order proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")
115
115
  return False
116
116
  for initial_valid_datetime in self.get_rrule(trade_date):
117
117
  initial_valid_date = initial_valid_datetime.date()
@@ -131,7 +131,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
131
131
  "rebalancing_model": self.rebalancing_model,
132
132
  },
133
133
  )
134
-
134
+ order_proposal.portfolio = self.portfolio
135
135
  if order_proposal.rebalancing_model == self.rebalancing_model:
136
136
  try:
137
137
  logger.info(
@@ -144,8 +144,8 @@ class Rebalancer(ComplexToStringMixin, models.Model):
144
144
  effective_portfolio=effective_portfolio,
145
145
  **self.parameters,
146
146
  )
147
- order_proposal.approve_workflow(
148
- approve_automatically=self.approve_order_proposal_automatically,
147
+ order_proposal.apply_workflow(
148
+ apply_automatically=self.apply_order_proposal_automatically,
149
149
  target_portfolio=target_portfolio,
150
150
  effective_portfolio=effective_portfolio,
151
151
  )
@@ -92,17 +92,21 @@ class TransactionMixin(models.Model):
92
92
  created = models.DateTimeField(auto_now_add=True)
93
93
  updated = models.DateTimeField(auto_now=True)
94
94
 
95
- def save(self, *args, **kwargs):
96
- if not getattr(self, "currency", None) and self.underlying_instrument:
95
+ def pre_save(self):
96
+ if self.underlying_instrument:
97
97
  self.currency = self.underlying_instrument.currency
98
- if self.currency_fx_rate is None:
99
- self.currency_fx_rate = self.underlying_instrument.currency.convert(
100
- self.value_date, self.portfolio.currency, exact_lookup=True
101
- )
98
+
102
99
  if self.price is not None and self.price_gross is None:
103
100
  self.price_gross = self.price
104
101
  elif self.price_gross is not None and self.price is None:
105
102
  self.price = self.price_gross
103
+
104
+ def save(self, *args, **kwargs):
105
+ self.pre_save()
106
+ if self.currency_fx_rate is None:
107
+ self.currency_fx_rate = self.underlying_instrument.currency.convert(
108
+ self.value_date, self.portfolio.currency, exact_lookup=True
109
+ )
106
110
  super().save(*args, **kwargs)
107
111
 
108
112
  class Meta:
@@ -0,0 +1,19 @@
1
+ from django.db.models import TextChoices
2
+
3
+ class ExecutionStatus(TextChoices):
4
+ PENDING = "PENDING", "Pending"
5
+ IN_DRAFT = "IN_DRAFT", "In Draft"
6
+ COMPLETED = "COMPLETED", "Completed"
7
+ CANCELLED = "CANCELLED", "Cancelled"
8
+ REJECTED = "REJECTED", "Rejected"
9
+ FAILED = "FAILED", "Failed"
10
+ UNKNOWN = "UNKNOWN", "Unknown"
11
+
12
+ class RoutingException(Exception):
13
+ def __init__(self, errors):
14
+ # messages: a list of strings
15
+ super().__init__() # You can pass a summary to the base Exception
16
+ self.errors = errors
17
+
18
+ def __str__(self):
19
+ return str(self.errors)
@@ -0,0 +1,57 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from wbportfolio.order_routing import ExecutionStatus
4
+ from wbportfolio.pms.typing import Order
5
+
6
+ class BaseCustodianAdapter(ABC):
7
+
8
+ def __init__(self, isin: str, **identifiers):
9
+ self.isin = isin
10
+
11
+ @property
12
+ def errors(self):
13
+ if not hasattr(self, '_errors'):
14
+ raise ValueError("is_valid needs to call before accessing errors")
15
+ return
16
+ @abstractmethod
17
+ def authenticate(self) -> bool:
18
+ """
19
+ Authenticate or renew tokens with the custodian API.
20
+ Raises an exception if authentication fails.
21
+ """
22
+ pass
23
+
24
+ @abstractmethod
25
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
26
+ """
27
+ Return the rebalance status as a string (in the custodian format)
28
+ """
29
+ pass
30
+
31
+ @abstractmethod
32
+ def is_valid(self) -> bool:
33
+ """
34
+ Check whether the given isin is valid and can be rebalanced
35
+ """
36
+ pass
37
+
38
+ @abstractmethod
39
+ def submit_rebalancing(self, orders: list[Order], as_draft: bool = True) -> tuple[list[Order], str]:
40
+ """
41
+ Submit a rebalance order for the certificate.
42
+ """
43
+ pass
44
+
45
+ @abstractmethod
46
+ def cancel_current_rebalancing(self) -> bool:
47
+ """
48
+ Cancel an existing rebalance order identified by ISIN.
49
+ """
50
+ pass
51
+
52
+ @abstractmethod
53
+ def get_current_rebalancing(self) -> list[Order]:
54
+ """
55
+ Fetch the current rebalance request details for a certificate.
56
+ """
57
+ pass