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.
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +10 -2
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +74 -31
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +549 -167
- wbportfolio/models/orders/orders.py +24 -11
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +77 -41
- wbportfolio/models/products.py +9 -0
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -1
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +25 -21
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +341 -155
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +47 -7
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {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
|
|
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
|
|
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.
|
|
201
|
-
|
|
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.
|
|
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()
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
657
|
-
# We
|
|
658
|
-
|
|
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
|
-
|
|
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="
|
|
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(
|
|
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
|
|
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(
|
|
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):
|
wbportfolio/models/products.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
111
|
+
status=OrderProposal.Status.APPLIED,
|
|
112
112
|
trade_date=trade_date,
|
|
113
113
|
rebalancing_model__isnull=True,
|
|
114
|
-
).exists(): # if a already
|
|
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.
|
|
148
|
-
|
|
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
|
|
96
|
-
if
|
|
95
|
+
def pre_save(self):
|
|
96
|
+
if self.underlying_instrument:
|
|
97
97
|
self.currency = self.underlying_instrument.currency
|
|
98
|
-
|
|
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
|