wbportfolio 1.55.8__py2.py3-none-any.whl → 1.59.4__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/orders/order_proposals.py +2 -0
- wbportfolio/admin/orders/orders.py +2 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/api_clients/ubs.py +23 -11
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +8 -3
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/orders/order_proposals.py +3 -6
- wbportfolio/filters/portfolios.py +18 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/handlers/asset_position.py +9 -5
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +2 -2
- wbportfolio/import_export/handlers/trade.py +4 -4
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/resources/trades.py +1 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +7 -3
- wbportfolio/models/builder.py +25 -5
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
- wbportfolio/models/orders/order_proposals.py +620 -490
- wbportfolio/models/orders/orders.py +237 -75
- wbportfolio/models/portfolio.py +79 -18
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +4 -1
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/claim.py +6 -5
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/trades.py +4 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +16 -0
- wbportfolio/order_routing/adapters/__init__.py +14 -6
- wbportfolio/order_routing/adapters/ubs.py +104 -70
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +115 -103
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/orders/order_proposals.py +6 -2
- wbportfolio/serializers/orders/orders.py +119 -26
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
- wbportfolio/tests/models/test_portfolios.py +9 -9
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
- wbportfolio/tests/rebalancing/test_models.py +2 -2
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +1 -1
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
- wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
- wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
- wbportfolio/viewsets/orders/order_proposals.py +92 -21
- wbportfolio/viewsets/orders/orders.py +79 -26
- wbportfolio/viewsets/portfolios.py +24 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/fdm/tasks.py +0 -42
- wbportfolio/models/orders/routing.py +0 -54
- wbportfolio/pms/trading/handler.py +0 -211
- /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,25 +3,30 @@ import math
|
|
|
3
3
|
from contextlib import suppress
|
|
4
4
|
from datetime import date, timedelta
|
|
5
5
|
from decimal import Decimal
|
|
6
|
-
from typing import Any, TypeVar
|
|
6
|
+
from typing import Any, Self, TypeVar
|
|
7
7
|
|
|
8
|
+
import pandas as pd
|
|
8
9
|
from celery import shared_task
|
|
9
10
|
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
10
12
|
from django.db import DatabaseError, models
|
|
11
13
|
from django.db.models import (
|
|
12
14
|
F,
|
|
13
15
|
OuterRef,
|
|
16
|
+
Q,
|
|
17
|
+
QuerySet,
|
|
14
18
|
Subquery,
|
|
15
19
|
Sum,
|
|
16
20
|
Value,
|
|
17
21
|
)
|
|
18
22
|
from django.db.models.functions import Coalesce, Round
|
|
19
|
-
from django.db.models.signals import post_save
|
|
23
|
+
from django.db.models.signals import post_save, pre_delete
|
|
20
24
|
from django.dispatch import receiver
|
|
21
25
|
from django.utils.functional import cached_property
|
|
22
|
-
from django.utils.translation import gettext_lazy
|
|
26
|
+
from django.utils.translation import gettext_lazy
|
|
23
27
|
from django_fsm import FSMField, transition
|
|
24
28
|
from pandas._libs.tslibs.offsets import BDay
|
|
29
|
+
from requests import HTTPError
|
|
25
30
|
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
26
31
|
from wbcore.contrib.authentication.models import User
|
|
27
32
|
from wbcore.contrib.icons import WBIcon
|
|
@@ -31,20 +36,20 @@ from wbcore.enums import RequestType
|
|
|
31
36
|
from wbcore.metadata.configs.buttons import ActionButton
|
|
32
37
|
from wbcore.models import WBModel
|
|
33
38
|
from wbcore.utils.models import CloneMixin
|
|
39
|
+
from wbfdm.enums import MarketData
|
|
34
40
|
from wbfdm.models import InstrumentPrice
|
|
35
41
|
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
42
|
+
from wbfdm.signals import investable_universe_updated
|
|
36
43
|
|
|
37
44
|
from wbportfolio.models.asset import AssetPosition
|
|
38
45
|
from wbportfolio.models.roles import PortfolioRole
|
|
39
|
-
from wbportfolio.pms.trading import TradingService
|
|
40
46
|
from wbportfolio.pms.typing import Order as OrderDTO
|
|
41
47
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
42
|
-
from wbportfolio.pms.typing import Position as PositionDTO
|
|
43
48
|
|
|
44
49
|
from ...order_routing import ExecutionStatus, RoutingException
|
|
45
|
-
from ...order_routing.
|
|
50
|
+
from ...order_routing.router import Router
|
|
51
|
+
from .. import Portfolio
|
|
46
52
|
from .orders import Order
|
|
47
|
-
from .routing import cancel_rebalancing, execute_orders, get_execution_status
|
|
48
53
|
|
|
49
54
|
logger = logging.getLogger("pms")
|
|
50
55
|
|
|
@@ -59,8 +64,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
59
64
|
PENDING = "PENDING", "Pending"
|
|
60
65
|
APPROVED = "APPROVED", "Approved"
|
|
61
66
|
DENIED = "DENIED", "Denied"
|
|
62
|
-
APPLIED = "APPLIED", "Applied"
|
|
63
67
|
EXECUTION = "EXECUTION", "Execution"
|
|
68
|
+
CONFIRMED = "CONFIRMED", "Confirmed"
|
|
64
69
|
FAILED = "FAILED", "Failed"
|
|
65
70
|
|
|
66
71
|
comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
|
|
@@ -96,12 +101,22 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
96
101
|
min_order_value = models.IntegerField(
|
|
97
102
|
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
98
103
|
)
|
|
104
|
+
min_weighting = models.DecimalField(
|
|
105
|
+
max_digits=9,
|
|
106
|
+
decimal_places=Order.ORDER_WEIGHTING_PRECISION,
|
|
107
|
+
default=Decimal(0),
|
|
108
|
+
help_text="The minimum weight allowed for this order proposal ",
|
|
109
|
+
verbose_name="Minimum Weight",
|
|
110
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
111
|
+
)
|
|
112
|
+
|
|
99
113
|
total_cash_weight = models.DecimalField(
|
|
100
114
|
default=Decimal("0"),
|
|
101
115
|
decimal_places=4,
|
|
102
116
|
max_digits=5,
|
|
103
117
|
verbose_name="Total Cash Weight",
|
|
104
118
|
help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
119
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
105
120
|
)
|
|
106
121
|
total_effective_portfolio_contribution = models.DecimalField(
|
|
107
122
|
default=Decimal("1"),
|
|
@@ -135,7 +150,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
135
150
|
)
|
|
136
151
|
]
|
|
137
152
|
|
|
153
|
+
def __str__(self) -> str:
|
|
154
|
+
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
155
|
+
|
|
138
156
|
def save(self, *args, **kwargs):
|
|
157
|
+
# if the order proposal is created, we default these fields with the portfolio default value for automatic value assignement
|
|
158
|
+
if not self.id and not self.min_order_value:
|
|
159
|
+
self.min_order_value = self.portfolio.default_order_proposal_min_order_value
|
|
160
|
+
if not self.id and not self.min_weighting:
|
|
161
|
+
self.min_weighting = self.portfolio.default_order_proposal_min_weighting
|
|
162
|
+
if not self.id and not self.total_cash_weight:
|
|
163
|
+
self.total_cash_weight = self.portfolio.default_order_proposal_total_cash_weight
|
|
139
164
|
# if a order proposal is created before the existing earliest order proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
|
|
140
165
|
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
141
166
|
# we need to set the inception date as the first order proposal trade date (and thus, the first position date). We expect a NAV at 100 then
|
|
@@ -154,21 +179,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
154
179
|
|
|
155
180
|
@cached_property
|
|
156
181
|
def portfolio_total_asset_value(self) -> Decimal:
|
|
157
|
-
return self.
|
|
158
|
-
|
|
159
|
-
@cached_property
|
|
160
|
-
def validated_trading_service(self) -> TradingService:
|
|
161
|
-
"""
|
|
162
|
-
This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
|
|
163
|
-
"""
|
|
164
|
-
target_portfolio = self.convert_to_portfolio()
|
|
165
|
-
|
|
166
|
-
return TradingService(
|
|
167
|
-
self.trade_date,
|
|
168
|
-
effective_portfolio=self._get_default_effective_portfolio(),
|
|
169
|
-
target_portfolio=target_portfolio,
|
|
170
|
-
total_target_weight=target_portfolio.total_weight,
|
|
171
|
-
)
|
|
182
|
+
return self.get_portfolio_total_asset_value()
|
|
172
183
|
|
|
173
184
|
@cached_property
|
|
174
185
|
def last_effective_date(self) -> date:
|
|
@@ -177,10 +188,10 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
177
188
|
except AssetPosition.DoesNotExist:
|
|
178
189
|
return self.value_date
|
|
179
190
|
|
|
180
|
-
@
|
|
181
|
-
def
|
|
191
|
+
@property
|
|
192
|
+
def custodian_router(self) -> Router | None:
|
|
182
193
|
try:
|
|
183
|
-
return self.portfolio.get_authenticated_custodian_adapter(raise_exception=True)
|
|
194
|
+
return Router(self.portfolio.get_authenticated_custodian_adapter(raise_exception=True))
|
|
184
195
|
except ValueError as e:
|
|
185
196
|
logger.warning("Error while instantiating custodian adapter: %s", e)
|
|
186
197
|
|
|
@@ -191,7 +202,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
191
202
|
@property
|
|
192
203
|
def previous_order_proposal(self) -> SelfOrderProposal | None:
|
|
193
204
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
194
|
-
trade_date__lt=self.trade_date, status=OrderProposal.Status.
|
|
205
|
+
trade_date__lt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
195
206
|
)
|
|
196
207
|
if future_proposals.exists():
|
|
197
208
|
return future_proposals.latest("trade_date")
|
|
@@ -200,7 +211,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
200
211
|
@property
|
|
201
212
|
def next_order_proposal(self) -> SelfOrderProposal | None:
|
|
202
213
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
203
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
214
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
204
215
|
)
|
|
205
216
|
if future_proposals.exists():
|
|
206
217
|
return future_proposals.earliest("trade_date")
|
|
@@ -218,6 +229,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
218
229
|
def total_expected_target_weight(self) -> Decimal:
|
|
219
230
|
return self.total_effective_portfolio_weight - self.total_cash_weight
|
|
220
231
|
|
|
232
|
+
@property
|
|
233
|
+
def can_be_confirmed(self) -> bool:
|
|
234
|
+
return self.portfolio.can_be_rebalanced and self.status == self.Status.APPROVED
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def can_be_applied(self):
|
|
238
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
239
|
+
|
|
221
240
|
@cached_property
|
|
222
241
|
def total_effective_portfolio_cash_weight(self) -> Decimal:
|
|
223
242
|
return self.portfolio.assets.filter(
|
|
@@ -225,10 +244,24 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
225
244
|
& (models.Q(underlying_quote__is_cash=True) | models.Q(underlying_quote__is_cash_equivalent=True))
|
|
226
245
|
).aggregate(Sum("weighting"))["weighting__sum"] or Decimal("0")
|
|
227
246
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
#
|
|
231
|
-
|
|
247
|
+
def get_portfolio_total_asset_value(self):
|
|
248
|
+
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
249
|
+
# return self.orders.annotate(
|
|
250
|
+
# effective_shares=Coalesce(
|
|
251
|
+
# Subquery(
|
|
252
|
+
# AssetPosition.objects.filter(
|
|
253
|
+
# underlying_quote=OuterRef("underlying_instrument"),
|
|
254
|
+
# date=self.last_effective_date,
|
|
255
|
+
# portfolio=self.portfolio,
|
|
256
|
+
# )
|
|
257
|
+
# .values("portfolio")
|
|
258
|
+
# .annotate(s=Sum("shares"))
|
|
259
|
+
# .values("s")[:1]
|
|
260
|
+
# ),
|
|
261
|
+
# Decimal(0),
|
|
262
|
+
# ),
|
|
263
|
+
# effective_total_value_fx_portfolio=F("effective_shares") * F("currency_fx_rate") * F("price"),
|
|
264
|
+
# ).aggregate(s=Sum("effective_total_value_fx_portfolio"))["s"] or Decimal(0.0)
|
|
232
265
|
|
|
233
266
|
def get_orders(self):
|
|
234
267
|
# TODO Issue here: the cash is subqueried on the portfolio, on portfolio such as the fund, there is multiple cash component, that we exclude in the orders (and use a unique cash position instead)
|
|
@@ -267,7 +300,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
267
300
|
effective_weight=Round(
|
|
268
301
|
models.Case(
|
|
269
302
|
models.When(total_effective_portfolio_contribution=Value(Decimal("0")), then=Value(Decimal("0"))),
|
|
270
|
-
default=F("contribution") / F("total_effective_portfolio_contribution"),
|
|
303
|
+
default=F("contribution") / F("total_effective_portfolio_contribution") - F("quantization_error"),
|
|
271
304
|
),
|
|
272
305
|
precision=Order.ORDER_WEIGHTING_PRECISION,
|
|
273
306
|
),
|
|
@@ -288,26 +321,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
288
321
|
target_shares=F("effective_shares") + F("shares"),
|
|
289
322
|
)
|
|
290
323
|
|
|
291
|
-
if total_estimated_effective_weight := orders.aggregate(s=models.Sum("effective_weight"))["s"]:
|
|
292
|
-
with suppress(Order.DoesNotExist):
|
|
293
|
-
largest_order = orders.latest("effective_weight")
|
|
294
|
-
if quant_error := self.total_effective_portfolio_weight - total_estimated_effective_weight:
|
|
295
|
-
orders = orders.annotate(
|
|
296
|
-
effective_weight=models.Case(
|
|
297
|
-
models.When(
|
|
298
|
-
id=largest_order.id,
|
|
299
|
-
then=models.F("effective_weight") + models.Value(Decimal(quant_error)),
|
|
300
|
-
),
|
|
301
|
-
default=models.F("effective_weight"),
|
|
302
|
-
),
|
|
303
|
-
target_weight=models.Case(
|
|
304
|
-
models.When(
|
|
305
|
-
id=largest_order.id,
|
|
306
|
-
then=models.F("target_weight") + models.Value(Decimal(quant_error)),
|
|
307
|
-
),
|
|
308
|
-
default=models.F("target_weight"),
|
|
309
|
-
),
|
|
310
|
-
)
|
|
311
324
|
return orders.annotate(
|
|
312
325
|
has_warnings=models.Case(
|
|
313
326
|
models.When(
|
|
@@ -317,153 +330,224 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
317
330
|
),
|
|
318
331
|
)
|
|
319
332
|
|
|
320
|
-
def
|
|
333
|
+
def get_trades_batch(self):
|
|
334
|
+
return self._get_default_effective_portfolio().get_orders(self.get_target_portfolio())
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def can_be_executed(self) -> bool:
|
|
338
|
+
return (
|
|
339
|
+
self.custodian_router is not None
|
|
340
|
+
and not self.has_non_successful_checks
|
|
341
|
+
and self.status == self.Status.APPROVED
|
|
342
|
+
and (not self.execution_status or self.execution_status == ExecutionStatus.CANCELLED)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def can_execute(self, user: User) -> bool:
|
|
346
|
+
return (not self.approver or user.is_superuser or user != self.approver.user_account) and self.can_be_executed
|
|
347
|
+
|
|
348
|
+
def prepare_orders_for_execution(self, prioritize_target_weight: bool = False) -> list[OrderDTO]:
|
|
349
|
+
"""Prepares executable orders by filtering and converting them for submission.
|
|
350
|
+
|
|
351
|
+
Filters out cash instruments and orders with zero weighting and shares, then
|
|
352
|
+
creates OrderDTOs for those having valid instrument identifiers. Orders with
|
|
353
|
+
unsupported asset classes or missing identifiers are marked as ignored with comments.
|
|
354
|
+
Updates ignored orders in bulk.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
prioritize_target_weight: If True, prioritize target weight over share quantities
|
|
358
|
+
when preparing order quantities.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
A list of OrderDTO objects ready for execution submission.
|
|
362
|
+
"""
|
|
321
363
|
executable_orders = []
|
|
364
|
+
updated_orders = []
|
|
365
|
+
self.orders.update(execution_status=Order.ExecutionStatus.IGNORED)
|
|
322
366
|
for order in (
|
|
323
|
-
self.get_orders()
|
|
367
|
+
self.get_orders()
|
|
368
|
+
.exclude(models.Q(underlying_instrument__is_cash=True) | (models.Q(weighting=0) & models.Q(shares=0)))
|
|
369
|
+
.select_related("underlying_instrument")
|
|
324
370
|
):
|
|
325
371
|
instrument = order.underlying_instrument
|
|
326
|
-
# we support only the instrument type provided by the Order DTO class
|
|
327
372
|
asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
|
|
373
|
+
|
|
328
374
|
try:
|
|
329
375
|
if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
|
|
376
|
+
quantity = {"target_weight": float(order.target_weight)}
|
|
377
|
+
if not prioritize_target_weight and order.shares:
|
|
378
|
+
quantity["shares"] = float(order.shares)
|
|
379
|
+
quantity["target_shares"] = (
|
|
380
|
+
float(order.target_shares) if order.target_shares is not None else None
|
|
381
|
+
)
|
|
382
|
+
|
|
330
383
|
executable_orders.append(
|
|
331
384
|
OrderDTO(
|
|
332
385
|
id=order.id,
|
|
333
386
|
asset_class=OrderDTO.AssetType[asset_class],
|
|
334
387
|
weighting=float(order.weighting),
|
|
335
|
-
target_weight=float(order.target_weight),
|
|
336
388
|
trade_date=order.value_date,
|
|
337
|
-
shares=float(order.shares) if order.shares is not None else None,
|
|
338
|
-
target_shares=float(order.target_shares) if order.target_shares is not None else None,
|
|
339
389
|
refinitiv_identifier_code=instrument.refinitiv_identifier_code,
|
|
340
|
-
bloomberg_ticker=instrument.
|
|
390
|
+
bloomberg_ticker=instrument.bloomberg_ticker,
|
|
341
391
|
sedol=instrument.sedol,
|
|
342
|
-
execution_instruction=
|
|
392
|
+
execution_instruction=order.execution_instruction,
|
|
393
|
+
execution_instruction_parameters=order.execution_instruction_parameters,
|
|
394
|
+
**quantity,
|
|
343
395
|
)
|
|
344
396
|
)
|
|
345
397
|
else:
|
|
346
|
-
order.
|
|
398
|
+
order.execution_status = Order.ExecutionStatus.FAILED
|
|
347
399
|
order.execution_comment = "Underlying instrument does not have a valid identifier."
|
|
348
|
-
|
|
400
|
+
updated_orders.append(order)
|
|
349
401
|
except (AttributeError, KeyError):
|
|
350
|
-
order.
|
|
402
|
+
order.execution_status = Order.ExecutionStatus.FAILED
|
|
351
403
|
order.execution_comment = f"Unsupported asset class {asset_class.title()}."
|
|
352
|
-
|
|
404
|
+
updated_orders.append(order)
|
|
353
405
|
|
|
406
|
+
Order.objects.bulk_update(updated_orders, ["execution_status", "execution_comment"])
|
|
354
407
|
return executable_orders
|
|
355
408
|
|
|
356
|
-
def
|
|
357
|
-
|
|
409
|
+
def handle_orders(self, orders: list[OrderDTO]):
|
|
410
|
+
"""Updates order statuses based on confirmed execution results.
|
|
358
411
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
"""
|
|
363
|
-
Converts the internal portfolio state and pending orders into a PortfolioDTO.
|
|
412
|
+
For each confirmed order, updates the corresponding database record with its
|
|
413
|
+
execution status, comment, and price when available. Orders not present in the
|
|
414
|
+
confirmation list are marked as failed.
|
|
364
415
|
|
|
365
416
|
Args:
|
|
366
|
-
|
|
367
|
-
with_cash: Include a cash position in the result if True.
|
|
368
|
-
use_desired_target_weight: Use desired target weights from orders if True.
|
|
369
|
-
|
|
370
|
-
Returns:
|
|
371
|
-
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
417
|
+
orders: List of confirmed order DTOs returned from the custodian router.
|
|
372
418
|
"""
|
|
373
|
-
|
|
419
|
+
leftover_orders = self.orders.filter(underlying_instrument__is_cash=False).all()
|
|
420
|
+
# portfolio_value = self.portfolio_total_asset_value
|
|
374
421
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
422
|
+
for confirmed_order in orders:
|
|
423
|
+
with suppress(Order.DoesNotExist):
|
|
424
|
+
order = leftover_orders.get(id=confirmed_order.id)
|
|
425
|
+
order.execution_status = Order.ExecutionStatus.CONFIRMED
|
|
426
|
+
order.execution_comment = confirmed_order.comment
|
|
427
|
+
# if execution_price := confirmed_order.execution_price:
|
|
428
|
+
# order.price = round(Decimal(execution_price), 2)
|
|
429
|
+
# order.execution_status = Order.ExecutionStatus.EXECUTED
|
|
430
|
+
# if shares := confirmed_order.shares:
|
|
431
|
+
# order.set_shares(Decimal(shares), portfolio_value)
|
|
432
|
+
# elif weighting := confirmed_order.weighting:
|
|
433
|
+
# order.set_weighting(Decimal(weighting), portfolio_value)
|
|
434
|
+
order.save()
|
|
435
|
+
leftover_orders = leftover_orders.exclude(id=order.id)
|
|
389
436
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
393
|
-
):
|
|
394
|
-
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
395
|
-
delta_weight = Decimal("0")
|
|
396
|
-
weighting = order.desired_target_weight
|
|
397
|
-
else:
|
|
398
|
-
delta_weight = order.weighting
|
|
399
|
-
weighting = order._previous_weight
|
|
437
|
+
leftover_orders.delete()
|
|
438
|
+
self.refresh_cash_position()
|
|
400
439
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
"delta_weight": delta_weight,
|
|
404
|
-
"shares": order._target_shares if not use_effective else order._effective_shares,
|
|
405
|
-
"price": order.price,
|
|
406
|
-
"currency_fx_rate": order.currency_fx_rate,
|
|
407
|
-
}
|
|
440
|
+
def execute_orders(self, prioritize_target_weight: bool = False):
|
|
441
|
+
"""Submits prepared orders for execution via the custodian router and updates status.
|
|
408
442
|
|
|
409
|
-
|
|
410
|
-
|
|
443
|
+
Prepares orders based on the target weight priority, submits them for execution,
|
|
444
|
+
handles confirmed orders on success, and records execution status and comments.
|
|
445
|
+
Logs and marks the execution as failed if submission raises an error.
|
|
411
446
|
|
|
412
|
-
|
|
447
|
+
Args:
|
|
448
|
+
prioritize_target_weight: Whether to prioritize target weights when preparing orders.
|
|
449
|
+
"""
|
|
450
|
+
self.status = self.Status.EXECUTION
|
|
451
|
+
orders = self.prepare_orders_for_execution(prioritize_target_weight=prioritize_target_weight)
|
|
413
452
|
try:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
453
|
+
confirmed_orders, (status, rebalancing_comment) = self.custodian_router.submit_rebalancing(orders)
|
|
454
|
+
self.handle_orders(confirmed_orders)
|
|
455
|
+
except (ValueError, RoutingException, HTTPError) as e:
|
|
456
|
+
logger.error(f"Could not execute orders proposal {self}: {e}")
|
|
457
|
+
status = ExecutionStatus.FAILED
|
|
458
|
+
rebalancing_comment = str(e)
|
|
459
|
+
self.execution_status = status
|
|
460
|
+
self.execution_comment = rebalancing_comment
|
|
461
|
+
self.save()
|
|
420
462
|
|
|
421
|
-
|
|
422
|
-
|
|
463
|
+
def refresh_execution_status(self):
|
|
464
|
+
"""Updates execution status from the custodian router and saves the model.
|
|
423
465
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
466
|
+
Retrieves the latest rebalance status and details, assigns them to the instance,
|
|
467
|
+
and persists changes to the database.
|
|
468
|
+
"""
|
|
469
|
+
self.execution_status, self.execution_status_detail = self.custodian_router.get_rebalance_status()
|
|
470
|
+
if self.execution_status == ExecutionStatus.COMPLETED:
|
|
471
|
+
self.execution_status = self.Status.CONFIRMED
|
|
472
|
+
self.save()
|
|
428
473
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if contribution:
|
|
432
|
-
drifted_weight = round(
|
|
433
|
-
weighting * (daily_return + Decimal("1")) / Decimal(contribution),
|
|
434
|
-
Order.ORDER_WEIGHTING_PRECISION,
|
|
435
|
-
)
|
|
436
|
-
else:
|
|
437
|
-
drifted_weight = weighting
|
|
438
|
-
weighting = drifted_weight + row["delta_weight"]
|
|
474
|
+
def cancel_rebalancing(self):
|
|
475
|
+
"""Cancels the ongoing rebalance via the custodian router and updates the model.
|
|
439
476
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
)
|
|
477
|
+
If cancellation succeeds, clears execution details, marks the status as cancelled,
|
|
478
|
+
saves the instance, and returns the cancellation status.
|
|
479
|
+
"""
|
|
480
|
+
cancel_rebalancing_status = self.custodian_router.cancel_rebalancing()
|
|
481
|
+
if cancel_rebalancing_status:
|
|
482
|
+
(
|
|
483
|
+
self.execution_comment,
|
|
484
|
+
self.execution_status_detail,
|
|
485
|
+
self.execution_status,
|
|
486
|
+
) = (
|
|
487
|
+
"",
|
|
488
|
+
"",
|
|
489
|
+
ExecutionStatus.CANCELLED,
|
|
454
490
|
)
|
|
455
|
-
|
|
491
|
+
self.save()
|
|
492
|
+
return cancel_rebalancing_status
|
|
456
493
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
494
|
+
def get_target_portfolio(self):
|
|
495
|
+
positions = []
|
|
496
|
+
instrument_ids = []
|
|
497
|
+
for order in self.get_orders():
|
|
498
|
+
pos = order.to_dto()
|
|
499
|
+
instrument_ids.append(pos.underlying_instrument)
|
|
500
|
+
positions.append(pos)
|
|
501
|
+
|
|
502
|
+
# insert latest market data
|
|
503
|
+
df = pd.DataFrame(
|
|
504
|
+
Instrument.objects.filter(id__in=instrument_ids).dl.market_data(
|
|
505
|
+
[MarketData.MARKET_CAPITALIZATION_CONSOLIDATED, MarketData.VOLUME, MarketData.CLOSE],
|
|
506
|
+
from_date=self.trade_date - timedelta(days=50),
|
|
507
|
+
to_date=self.trade_date,
|
|
508
|
+
target_currency="USD",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
df["volume_50d"] = df["volume"]
|
|
512
|
+
df = (
|
|
513
|
+
df[
|
|
514
|
+
[
|
|
515
|
+
"valuation_date",
|
|
516
|
+
"instrument_id",
|
|
517
|
+
"volume",
|
|
518
|
+
"volume_50d",
|
|
519
|
+
"close",
|
|
520
|
+
"market_capitalization_consolidated",
|
|
521
|
+
]
|
|
522
|
+
]
|
|
523
|
+
.sort_values(by="valuation_date")
|
|
524
|
+
.groupby("instrument_id")
|
|
525
|
+
.agg(
|
|
526
|
+
{
|
|
527
|
+
"volume": "last",
|
|
528
|
+
"volume_50d": "mean",
|
|
529
|
+
"close": "last",
|
|
530
|
+
"market_capitalization_consolidated": "last",
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
df["volume_usd"] = df.volume * df.close
|
|
535
|
+
|
|
536
|
+
for pos in positions:
|
|
537
|
+
if pos.underlying_instrument in df.index:
|
|
538
|
+
pos.market_capitalization_usd = df.loc[pos.underlying_instrument, "market_capitalization_consolidated"]
|
|
539
|
+
pos.volume_usd = (
|
|
540
|
+
df.loc[pos.underlying_instrument, "volume"] * df.loc[pos.underlying_instrument, "close"]
|
|
541
|
+
)
|
|
542
|
+
if pos.shares:
|
|
543
|
+
if volume_50d := df.loc[pos.underlying_instrument, "volume_50d"]:
|
|
544
|
+
pos.daily_liquidity = float(pos.shares) / volume_50d / 0.33
|
|
545
|
+
if pos.market_capitalization_usd:
|
|
546
|
+
pos.market_share = (
|
|
547
|
+
float(pos.shares)
|
|
548
|
+
* df.loc[pos.underlying_instrument, "close"]
|
|
549
|
+
/ pos.market_capitalization_usd
|
|
550
|
+
)
|
|
467
551
|
|
|
468
552
|
return PortfolioDTO(positions)
|
|
469
553
|
|
|
@@ -497,35 +581,26 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
497
581
|
|
|
498
582
|
return order_proposal_clone
|
|
499
583
|
|
|
500
|
-
def normalize_orders(self):
|
|
584
|
+
def normalize_orders(self, total_cash_weight: Decimal):
|
|
501
585
|
"""
|
|
502
|
-
|
|
503
|
-
The existing order will be modified directly with the given normalization factor
|
|
586
|
+
Normalize the orders to accomodate the given cash weight
|
|
504
587
|
"""
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
effective_portfolio=self._get_default_effective_portfolio(),
|
|
508
|
-
target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
|
|
509
|
-
total_target_weight=self.total_expected_target_weight,
|
|
510
|
-
)
|
|
511
|
-
leftovers_orders = self.orders.all()
|
|
512
|
-
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
513
|
-
with suppress(Order.DoesNotExist):
|
|
514
|
-
order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
|
|
515
|
-
order.weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
|
|
516
|
-
order.save()
|
|
517
|
-
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
518
|
-
leftovers_orders.delete()
|
|
519
|
-
self.fix_quantization()
|
|
588
|
+
self.total_cash_weight = total_cash_weight
|
|
589
|
+
self.reset_orders()
|
|
520
590
|
|
|
521
591
|
def fix_quantization(self):
|
|
522
592
|
if self.orders.exists():
|
|
523
593
|
orders = self.get_orders()
|
|
524
|
-
t_weight = orders.aggregate(models.Sum("
|
|
594
|
+
t_weight = orders.aggregate(models.Sum("effective_weight"))["effective_weight__sum"] or Decimal("0.0")
|
|
595
|
+
quantization_error = orders.aggregate(models.Sum("quantization_error"))[
|
|
596
|
+
"quantization_error__sum"
|
|
597
|
+
] or Decimal("0.0")
|
|
525
598
|
# we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
|
|
526
|
-
if
|
|
527
|
-
|
|
528
|
-
|
|
599
|
+
if t_weight and (
|
|
600
|
+
quantize_error := ((t_weight + quantization_error) - self.total_effective_portfolio_weight)
|
|
601
|
+
):
|
|
602
|
+
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("effective_weight")
|
|
603
|
+
biggest_order.quantization_error = quantize_error
|
|
529
604
|
biggest_order.save()
|
|
530
605
|
|
|
531
606
|
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
@@ -537,16 +612,93 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
537
612
|
return self.rebalancing_model.get_target_portfolio(
|
|
538
613
|
self.portfolio, self.trade_date, self.value_date, **params
|
|
539
614
|
)
|
|
540
|
-
return self.
|
|
615
|
+
return self._get_default_effective_portfolio(
|
|
616
|
+
include_delta_weight=True, use_desired_target_weight=use_desired_target_weight
|
|
617
|
+
)
|
|
541
618
|
|
|
542
|
-
def _get_default_effective_portfolio(
|
|
543
|
-
|
|
619
|
+
def _get_default_effective_portfolio(
|
|
620
|
+
self, include_delta_weight: bool = False, use_desired_target_weight: bool = False
|
|
621
|
+
):
|
|
622
|
+
"""
|
|
623
|
+
Converts the internal portfolio state and pending orders into a PortfolioDTO.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
627
|
+
"""
|
|
628
|
+
portfolio = {}
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
analytic_portfolio = self.portfolio.get_analytic_portfolio(self.last_effective_date, use_dl=True)
|
|
632
|
+
last_returns, contribution = analytic_portfolio.get_contributions()
|
|
633
|
+
last_returns = last_returns.to_dict()
|
|
634
|
+
effective_weights = analytic_portfolio.get_next_weights()
|
|
635
|
+
except ValueError:
|
|
636
|
+
effective_weights, last_returns, contribution = {}, {}, 1
|
|
637
|
+
self.total_effective_portfolio_contribution = Decimal(contribution)
|
|
638
|
+
# 1. Gather all non-cash, positively weighted assets from the existing portfolio.
|
|
639
|
+
for asset in self.portfolio.assets.filter(
|
|
640
|
+
date=self.last_effective_date,
|
|
641
|
+
weighting__gt=0,
|
|
642
|
+
):
|
|
643
|
+
portfolio[asset.underlying_quote] = {
|
|
644
|
+
"shares": asset._shares,
|
|
645
|
+
"weighting": Decimal(effective_weights.get(asset.underlying_quote.id, asset.weighting))
|
|
646
|
+
if not use_desired_target_weight
|
|
647
|
+
else Decimal("0"),
|
|
648
|
+
"price": asset._price,
|
|
649
|
+
"currency_fx_rate": asset._currency_fx_rate,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
# 2. Add or update non-cash orders, possibly overriding weights.
|
|
653
|
+
for order in self.get_orders().filter(
|
|
654
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
655
|
+
):
|
|
656
|
+
order.daily_return = last_returns.get(order.underlying_instrument.id, 0)
|
|
657
|
+
if use_desired_target_weight and order.desired_target_weight:
|
|
658
|
+
weighting = order.desired_target_weight
|
|
659
|
+
else:
|
|
660
|
+
weighting = order._effective_weight
|
|
661
|
+
if include_delta_weight:
|
|
662
|
+
weighting += order.weighting
|
|
663
|
+
portfolio[order.underlying_instrument] = {
|
|
664
|
+
"weighting": weighting,
|
|
665
|
+
"shares": order._effective_shares,
|
|
666
|
+
"price": order.price,
|
|
667
|
+
"currency_fx_rate": order.currency_fx_rate,
|
|
668
|
+
}
|
|
669
|
+
positions = []
|
|
670
|
+
|
|
671
|
+
# 5. Build PositionDTO objects for all instruments.
|
|
672
|
+
for instrument, row in portfolio.items():
|
|
673
|
+
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
674
|
+
# Assemble the position object
|
|
675
|
+
pos = Order.create_dto(
|
|
676
|
+
instrument,
|
|
677
|
+
row["weighting"],
|
|
678
|
+
row["price"],
|
|
679
|
+
self.last_effective_date,
|
|
680
|
+
shares=row["shares"],
|
|
681
|
+
daily_return=daily_return,
|
|
682
|
+
currency_fx_rate=row["currency_fx_rate"],
|
|
683
|
+
)
|
|
684
|
+
positions.append(pos)
|
|
685
|
+
total_weighting = sum(map(lambda pos: pos.weighting, positions))
|
|
686
|
+
# 6. Optionally include a cash position to balance the total weighting.
|
|
687
|
+
if (
|
|
688
|
+
portfolio
|
|
689
|
+
and total_weighting
|
|
690
|
+
and self.total_effective_portfolio_weight
|
|
691
|
+
and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
|
|
692
|
+
):
|
|
693
|
+
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
694
|
+
positions.append(cash_position._build_dto())
|
|
695
|
+
return PortfolioDTO(positions)
|
|
544
696
|
|
|
545
697
|
def reset_orders(
|
|
546
698
|
self,
|
|
699
|
+
effective_portfolio: PortfolioDTO
|
|
700
|
+
| None = None, # we need to have this parameter as sometime we want to get the effective portfolio from drifted weight (unsaved)
|
|
547
701
|
target_portfolio: PortfolioDTO | None = None,
|
|
548
|
-
effective_portfolio: PortfolioRole | None = None,
|
|
549
|
-
validate_order: bool = True,
|
|
550
702
|
use_desired_target_weight: bool = False,
|
|
551
703
|
):
|
|
552
704
|
"""
|
|
@@ -556,38 +708,26 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
556
708
|
self.orders.all().delete()
|
|
557
709
|
else:
|
|
558
710
|
self.orders.filter(underlying_instrument__is_cash=True).delete()
|
|
711
|
+
self.orders.update(quantization_error=0)
|
|
559
712
|
# delete all existing orders
|
|
560
713
|
# Get effective and target portfolio
|
|
561
|
-
if not target_portfolio:
|
|
562
|
-
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
563
714
|
if not effective_portfolio:
|
|
564
715
|
effective_portfolio = self._get_default_effective_portfolio()
|
|
565
|
-
if target_portfolio:
|
|
566
|
-
|
|
567
|
-
self.trade_date,
|
|
568
|
-
effective_portfolio=effective_portfolio,
|
|
569
|
-
target_portfolio=target_portfolio,
|
|
570
|
-
total_target_weight=self.total_expected_target_weight,
|
|
571
|
-
)
|
|
572
|
-
if validate_order:
|
|
573
|
-
service.is_valid()
|
|
574
|
-
orders = service.validated_trades
|
|
575
|
-
else:
|
|
576
|
-
orders = service.trades_batch.trades_map.values()
|
|
716
|
+
if not target_portfolio:
|
|
717
|
+
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
577
718
|
|
|
719
|
+
if self.total_cash_weight:
|
|
720
|
+
target_portfolio = target_portfolio.normalize_cash(self.total_cash_weight)
|
|
721
|
+
if target_portfolio:
|
|
578
722
|
objs = []
|
|
579
|
-
|
|
723
|
+
portfolio_value = self.portfolio_total_asset_value
|
|
724
|
+
for order_dto in effective_portfolio.get_orders(target_portfolio):
|
|
580
725
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
581
|
-
currency_fx_rate = instrument.currency.convert(
|
|
582
|
-
self.value_date, self.portfolio.currency, exact_lookup=True
|
|
583
|
-
)
|
|
584
726
|
# we cannot do a bulk-create because Order is a multi table inheritance
|
|
585
727
|
weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
|
|
586
728
|
daily_return = order_dto.daily_return
|
|
587
729
|
try:
|
|
588
730
|
order = self.orders.get(underlying_instrument=instrument)
|
|
589
|
-
order.weighting = weighting
|
|
590
|
-
order.currency_fx_rate = currency_fx_rate
|
|
591
731
|
order.daily_return = daily_return
|
|
592
732
|
except Order.DoesNotExist:
|
|
593
733
|
order = Order(
|
|
@@ -596,17 +736,21 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
596
736
|
value_date=self.trade_date,
|
|
597
737
|
weighting=weighting,
|
|
598
738
|
daily_return=daily_return,
|
|
599
|
-
currency_fx_rate=currency_fx_rate,
|
|
600
739
|
)
|
|
601
|
-
order.desired_target_weight = order_dto.target_weight
|
|
602
740
|
order.order_type = Order.get_type(
|
|
603
|
-
weighting, round(order_dto.
|
|
741
|
+
weighting, round(order_dto.effective_weight, 8), round(order_dto.target_weight, 8)
|
|
604
742
|
)
|
|
743
|
+
order.quantization_error = order_dto.effective_quantization_error
|
|
744
|
+
if order_dto.price:
|
|
745
|
+
order.price = order_dto.price
|
|
605
746
|
order.pre_save()
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
#
|
|
747
|
+
order.set_weighting(weighting, portfolio_value)
|
|
748
|
+
order.desired_target_weight = order_dto.target_weight
|
|
749
|
+
|
|
750
|
+
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
751
|
+
if not order.price and order.weighting > 0:
|
|
752
|
+
order.price = Decimal("0.0")
|
|
753
|
+
order.weighting = -order_dto.effective_weight
|
|
610
754
|
objs.append(order)
|
|
611
755
|
Order.objects.bulk_create(
|
|
612
756
|
objs,
|
|
@@ -620,7 +764,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
620
764
|
"price",
|
|
621
765
|
"price_gross",
|
|
622
766
|
"desired_target_weight",
|
|
623
|
-
|
|
767
|
+
"quantization_error",
|
|
768
|
+
"shares",
|
|
624
769
|
],
|
|
625
770
|
unique_fields=["order_proposal", "underlying_instrument"],
|
|
626
771
|
update_conflicts=True,
|
|
@@ -631,41 +776,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
631
776
|
target_weight=0, effective_weight=0
|
|
632
777
|
).delete()
|
|
633
778
|
self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
|
|
634
|
-
|
|
635
|
-
self.total_effective_portfolio_contribution = effective_portfolio.portfolio_contribution
|
|
779
|
+
self.fix_quantization()
|
|
636
780
|
self.save()
|
|
637
781
|
|
|
638
|
-
def
|
|
639
|
-
self
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
782
|
+
def refresh_cash_position(self):
|
|
783
|
+
self.total_cash_weight = self.total_effective_portfolio_weight - self.get_orders().filter(
|
|
784
|
+
underlying_instrument__is_cash=False
|
|
785
|
+
).aggregate(s=Sum("target_weight"))["s"] or Decimal("0")
|
|
786
|
+
cash_order = None
|
|
787
|
+
try:
|
|
788
|
+
cash_order = Order.objects.get(order_proposal=self, underlying_instrument=self.cash_component)
|
|
789
|
+
except Order.DoesNotExist:
|
|
790
|
+
if self.total_cash_weight:
|
|
791
|
+
cash_order = Order.objects.create(
|
|
792
|
+
order_proposal=self, underlying_instrument=self.cash_component, weighting=Decimal("0")
|
|
793
|
+
)
|
|
794
|
+
if cash_order:
|
|
795
|
+
cash_order.weighting = self.total_cash_weight - cash_order._previous_weight
|
|
796
|
+
cash_order.save()
|
|
797
|
+
|
|
798
|
+
def refresh_returns(self):
|
|
799
|
+
weights = {
|
|
800
|
+
row[0]: float(row[1]) for row in self.get_orders().values_list("underlying_instrument", "previous_weight")
|
|
801
|
+
}
|
|
802
|
+
last_returns, contribution = self.portfolio.get_analytic_portfolio(
|
|
803
|
+
self.value_date, weights=weights, use_dl=True
|
|
804
|
+
).get_contributions()
|
|
805
|
+
last_returns = last_returns.to_dict()
|
|
806
|
+
orders_to_update = []
|
|
807
|
+
for order in self.orders.all():
|
|
808
|
+
with suppress(KeyError):
|
|
809
|
+
order.price = self.portfolio.builder.prices[self.trade_date][order.underlying_instrument.id]
|
|
810
|
+
try:
|
|
811
|
+
order.daily_return = last_returns[order.underlying_instrument.id]
|
|
812
|
+
except KeyError:
|
|
813
|
+
order.daily_return = Decimal("1.0")
|
|
814
|
+
order.quantization_error = Decimal("0")
|
|
815
|
+
orders_to_update.append(order)
|
|
816
|
+
Order.objects.bulk_update(orders_to_update, ["daily_return", "price", "quantization_error"])
|
|
817
|
+
self.total_effective_portfolio_contribution = Decimal(contribution)
|
|
818
|
+
self.save()
|
|
819
|
+
# ensure that sell orders keep having target weight at zero (might happens when returns are refreshed expost)
|
|
820
|
+
for order in self.get_orders().filter(Q(order_type=Order.Type.SELL) & ~Q(weighting=-F("target_weight"))):
|
|
821
|
+
order.weighting = -order.effective_weight
|
|
822
|
+
order.save()
|
|
823
|
+
|
|
824
|
+
# At this point, user needs to manually modify the orders in order to account for ex-post change. I am not sure we should we quantization at that point. To be monitored
|
|
825
|
+
# self.fix_quantization()
|
|
669
826
|
|
|
670
827
|
def replay(
|
|
671
828
|
self,
|
|
@@ -677,7 +834,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
677
834
|
last_order_proposal = self
|
|
678
835
|
last_order_proposal_created = False
|
|
679
836
|
self.portfolio.load_builder_returns((self.trade_date - BDay(3)).date(), date.today())
|
|
680
|
-
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.
|
|
837
|
+
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.CONFIRMED:
|
|
681
838
|
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
682
839
|
if not last_order_proposal_created:
|
|
683
840
|
if reapply_order_proposal or last_order_proposal.rebalancing_model:
|
|
@@ -689,7 +846,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
689
846
|
else:
|
|
690
847
|
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
691
848
|
last_order_proposal.reset_orders(**reset_order_kwargs)
|
|
692
|
-
if last_order_proposal.status != OrderProposal.Status.
|
|
849
|
+
if last_order_proposal.status != OrderProposal.Status.CONFIRMED:
|
|
693
850
|
break
|
|
694
851
|
next_order_proposal = last_order_proposal.next_order_proposal
|
|
695
852
|
if next_order_proposal:
|
|
@@ -738,7 +895,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
738
895
|
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
739
896
|
).delete()
|
|
740
897
|
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
741
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
898
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
742
899
|
):
|
|
743
900
|
future_order_proposal.revert()
|
|
744
901
|
future_order_proposal.save()
|
|
@@ -772,7 +929,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
772
929
|
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
773
930
|
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
774
931
|
)
|
|
775
|
-
|
|
776
932
|
# If the price is valid, calculate and return the estimated shares
|
|
777
933
|
if price_fx_portfolio:
|
|
778
934
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
@@ -840,8 +996,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
840
996
|
is_estimated=False,
|
|
841
997
|
)
|
|
842
998
|
|
|
843
|
-
#
|
|
844
|
-
|
|
999
|
+
# WORKFLOW METHODS
|
|
845
1000
|
@transition(
|
|
846
1001
|
field=status,
|
|
847
1002
|
source=Status.DRAFT,
|
|
@@ -852,7 +1007,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
852
1007
|
custom={
|
|
853
1008
|
"_transition_button": ActionButton(
|
|
854
1009
|
method=RequestType.PATCH,
|
|
855
|
-
identifiers=("wbportfolio:
|
|
1010
|
+
identifiers=("wbportfolio:order",),
|
|
856
1011
|
icon=WBIcon.SEND.icon,
|
|
857
1012
|
key="submit",
|
|
858
1013
|
label="Submit",
|
|
@@ -861,51 +1016,28 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
861
1016
|
)
|
|
862
1017
|
},
|
|
863
1018
|
)
|
|
864
|
-
def submit(self, by=None, description=None, **kwargs):
|
|
1019
|
+
def submit(self, by=None, description=None, pretrade_check: bool = True, **kwargs):
|
|
865
1020
|
orders = []
|
|
866
1021
|
orders_validation_warnings = []
|
|
867
1022
|
qs = self.get_orders()
|
|
868
|
-
total_target_weight = Decimal("0")
|
|
869
1023
|
for order in qs:
|
|
870
1024
|
order_warnings = order.submit(
|
|
871
1025
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
872
1026
|
)
|
|
1027
|
+
|
|
873
1028
|
if order_warnings:
|
|
874
1029
|
orders_validation_warnings.extend(order_warnings)
|
|
875
1030
|
orders.append(order)
|
|
876
|
-
total_target_weight += order._target_weight
|
|
877
1031
|
|
|
878
1032
|
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
estimated_cash_position._build_dto()
|
|
884
|
-
)
|
|
885
|
-
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
886
|
-
self.total_cash_weight = Decimal("1") - total_target_weight
|
|
1033
|
+
if pretrade_check:
|
|
1034
|
+
self.evaluate_pretrade_checks()
|
|
1035
|
+
else:
|
|
1036
|
+
self.refresh_cash_position()
|
|
887
1037
|
return orders_validation_warnings
|
|
888
1038
|
|
|
889
1039
|
def can_submit(self):
|
|
890
1040
|
errors = dict()
|
|
891
|
-
errors_list = []
|
|
892
|
-
service = self.validated_trading_service
|
|
893
|
-
try:
|
|
894
|
-
service.is_valid(ignore_error=True)
|
|
895
|
-
# if service.trades_batch.total_abs_delta_weight == 0:
|
|
896
|
-
# errors_list.append(
|
|
897
|
-
# "There is no change detected in this order proposal. Please submit at last one valid order"
|
|
898
|
-
# )
|
|
899
|
-
if len(service.validated_trades) == 0:
|
|
900
|
-
errors_list.append(_("There is no valid order on this proposal"))
|
|
901
|
-
if service.errors:
|
|
902
|
-
errors_list.extend(service.errors)
|
|
903
|
-
if errors_list:
|
|
904
|
-
errors["non_field_errors"] = errors_list
|
|
905
|
-
except ValidationError:
|
|
906
|
-
errors["non_field_errors"] = service.errors
|
|
907
|
-
with suppress(KeyError):
|
|
908
|
-
del self.__dict__["validated_trading_service"]
|
|
909
1041
|
return errors
|
|
910
1042
|
|
|
911
1043
|
@transition(
|
|
@@ -919,7 +1051,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
919
1051
|
custom={
|
|
920
1052
|
"_transition_button": ActionButton(
|
|
921
1053
|
method=RequestType.PATCH,
|
|
922
|
-
identifiers=("wbportfolio:
|
|
1054
|
+
identifiers=("wbportfolio:order",),
|
|
923
1055
|
icon=WBIcon.APPROVE.icon,
|
|
924
1056
|
key="approve",
|
|
925
1057
|
label="Approve",
|
|
@@ -928,14 +1060,70 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
928
1060
|
)
|
|
929
1061
|
},
|
|
930
1062
|
)
|
|
931
|
-
def approve(self, by=None,
|
|
1063
|
+
def approve(self, by=None, replay: bool = True, **kwargs):
|
|
932
1064
|
if by:
|
|
933
1065
|
self.approver = getattr(by, "profile", None)
|
|
934
1066
|
elif not self.approver:
|
|
935
1067
|
self.approver = self.creator
|
|
1068
|
+
if self.portfolio.can_be_rebalanced:
|
|
1069
|
+
self.apply()
|
|
1070
|
+
if replay:
|
|
1071
|
+
replay_as_task.apply_async(
|
|
1072
|
+
(self.id,),
|
|
1073
|
+
{
|
|
1074
|
+
"user_id": by.id if by else None,
|
|
1075
|
+
"broadcast_changes_at_date": False,
|
|
1076
|
+
"reapply_order_proposal": True,
|
|
1077
|
+
},
|
|
1078
|
+
countdown=10,
|
|
1079
|
+
)
|
|
1080
|
+
if by and self.custodian_router:
|
|
1081
|
+
for user in User.objects.exclude(id=by.id).filter(
|
|
1082
|
+
profile__in=PortfolioRole.portfolio_managers(), is_active=True
|
|
1083
|
+
):
|
|
1084
|
+
send_notification(
|
|
1085
|
+
code="wbportfolio.portfolio.action_done",
|
|
1086
|
+
title="An Order Proposal was approved and is waiting execution",
|
|
1087
|
+
body=f"The order proposal {self} has been approved by {by.profile.full_name} and is now pending execution. Please review the orders carefully and proceed with execution if appropriate.",
|
|
1088
|
+
user=user,
|
|
1089
|
+
reverse_name="wbportfolio:orderproposal-detail",
|
|
1090
|
+
reverse_args=[self.id],
|
|
1091
|
+
)
|
|
936
1092
|
|
|
937
|
-
|
|
938
|
-
|
|
1093
|
+
@transition(
|
|
1094
|
+
field=status,
|
|
1095
|
+
source=Status.APPROVED,
|
|
1096
|
+
target=Status.CONFIRMED,
|
|
1097
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1098
|
+
user.profile, portfolio=instance.portfolio
|
|
1099
|
+
)
|
|
1100
|
+
and instance.portfolio.can_be_rebalanced,
|
|
1101
|
+
custom={
|
|
1102
|
+
"_transition_button": ActionButton(
|
|
1103
|
+
method=RequestType.PATCH,
|
|
1104
|
+
identifiers=("wbportfolio:order",),
|
|
1105
|
+
icon=WBIcon.LOCK.icon,
|
|
1106
|
+
key="confirm",
|
|
1107
|
+
label="Confirm",
|
|
1108
|
+
action_label="Lock order proposal",
|
|
1109
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1110
|
+
)
|
|
1111
|
+
},
|
|
1112
|
+
)
|
|
1113
|
+
def confirm(self, by=None, replay: bool = True, **kwargs):
|
|
1114
|
+
self.refresh_cash_position()
|
|
1115
|
+
if self.portfolio.can_be_rebalanced:
|
|
1116
|
+
self.apply()
|
|
1117
|
+
if replay:
|
|
1118
|
+
replay_as_task.apply_async(
|
|
1119
|
+
(self.id,),
|
|
1120
|
+
{
|
|
1121
|
+
"user_id": by.id if by else None,
|
|
1122
|
+
"broadcast_changes_at_date": False,
|
|
1123
|
+
"reapply_order_proposal": True,
|
|
1124
|
+
},
|
|
1125
|
+
countdown=10,
|
|
1126
|
+
)
|
|
939
1127
|
|
|
940
1128
|
@transition(
|
|
941
1129
|
field=status,
|
|
@@ -948,7 +1136,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
948
1136
|
custom={
|
|
949
1137
|
"_transition_button": ActionButton(
|
|
950
1138
|
method=RequestType.PATCH,
|
|
951
|
-
identifiers=("wbportfolio:
|
|
1139
|
+
identifiers=("wbportfolio:order",),
|
|
952
1140
|
icon=WBIcon.DENY.icon,
|
|
953
1141
|
key="deny",
|
|
954
1142
|
label="Deny",
|
|
@@ -963,85 +1151,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
963
1151
|
def can_deny(self):
|
|
964
1152
|
pass
|
|
965
1153
|
|
|
966
|
-
|
|
967
|
-
def can_be_applied(self):
|
|
968
|
-
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
969
|
-
|
|
970
|
-
@transition(
|
|
971
|
-
field=status,
|
|
972
|
-
source=Status.APPROVED,
|
|
973
|
-
target=Status.APPLIED,
|
|
974
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
975
|
-
user.profile, portfolio=instance.portfolio
|
|
976
|
-
)
|
|
977
|
-
and instance.can_be_applied,
|
|
978
|
-
custom={
|
|
979
|
-
"_transition_button": ActionButton(
|
|
980
|
-
method=RequestType.PATCH,
|
|
981
|
-
identifiers=("wbportfolio:orderproposal",),
|
|
982
|
-
icon=WBIcon.SAVE.icon,
|
|
983
|
-
key="apply",
|
|
984
|
-
label="Apply",
|
|
985
|
-
action_label="Apply",
|
|
986
|
-
)
|
|
987
|
-
},
|
|
988
|
-
)
|
|
989
|
-
def apply(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
1154
|
+
def apply(self):
|
|
990
1155
|
# We validate order which will create or update the initial asset positions
|
|
991
1156
|
if not self.portfolio.can_be_rebalanced:
|
|
992
1157
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
993
|
-
|
|
1158
|
+
|
|
994
1159
|
# We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
|
|
995
|
-
|
|
996
|
-
assets = {}
|
|
997
|
-
for order in self.get_orders():
|
|
998
|
-
with suppress(ValueError):
|
|
999
|
-
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
1000
|
-
if (
|
|
1001
|
-
order.underlying_instrument != estimated_cash_position.underlying_quote
|
|
1002
|
-
and order._target_weight > 0
|
|
1003
|
-
):
|
|
1004
|
-
assets[order.underlying_instrument.id] = order._target_weight
|
|
1005
|
-
|
|
1006
|
-
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
1007
|
-
if estimated_cash_position.weighting > 0 and len(assets) > 0:
|
|
1008
|
-
warnings.append(
|
|
1009
|
-
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
1010
|
-
)
|
|
1011
|
-
assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
|
|
1160
|
+
target_portfolio = self.get_target_portfolio()
|
|
1161
|
+
assets = {i: float(pos.weighting) for i, pos in target_portfolio.positions_map.items()}
|
|
1012
1162
|
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
1013
1163
|
force_save=True, is_estimated=False, delete_leftovers=True
|
|
1014
1164
|
)
|
|
1015
|
-
if replay and self.portfolio.is_manageable:
|
|
1016
|
-
replay_as_task.delay(
|
|
1017
|
-
self.id, user_id=by.id if by else None, broadcast_changes_at_date=False, reapply_order_proposal=True
|
|
1018
|
-
)
|
|
1019
|
-
if by:
|
|
1020
|
-
self.approver = by.profile
|
|
1021
|
-
return warnings
|
|
1022
|
-
|
|
1023
|
-
def can_apply(self):
|
|
1024
|
-
errors = dict()
|
|
1025
|
-
orders = self.get_orders()
|
|
1026
|
-
if not self.portfolio.can_be_rebalanced:
|
|
1027
|
-
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
1028
|
-
if not orders.exists():
|
|
1029
|
-
errors["non_field_errors"] = [
|
|
1030
|
-
_("At least one order needs to be submitted to be able to apply this proposal")
|
|
1031
|
-
]
|
|
1032
|
-
if not self.portfolio.can_be_rebalanced:
|
|
1033
|
-
errors["portfolio"] = [
|
|
1034
|
-
[_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
|
|
1035
|
-
]
|
|
1036
|
-
if self.has_non_successful_checks:
|
|
1037
|
-
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
1038
|
-
if orders.filter(has_warnings=True).filter(
|
|
1039
|
-
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
1040
|
-
):
|
|
1041
|
-
errors["non_field_errors"] = [
|
|
1042
|
-
_("There is warning that needs to be addresses on the orders before approval.")
|
|
1043
|
-
]
|
|
1044
|
-
return errors
|
|
1045
1165
|
|
|
1046
1166
|
@transition(
|
|
1047
1167
|
field=status,
|
|
@@ -1055,7 +1175,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1055
1175
|
custom={
|
|
1056
1176
|
"_transition_button": ActionButton(
|
|
1057
1177
|
method=RequestType.PATCH,
|
|
1058
|
-
identifiers=("wbportfolio:
|
|
1178
|
+
identifiers=("wbportfolio:order",),
|
|
1059
1179
|
icon=WBIcon.UNDO.icon,
|
|
1060
1180
|
key="backtodraft",
|
|
1061
1181
|
label="Back to Draft",
|
|
@@ -1065,8 +1185,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1065
1185
|
},
|
|
1066
1186
|
)
|
|
1067
1187
|
def backtodraft(self, **kwargs):
|
|
1068
|
-
with suppress(KeyError):
|
|
1069
|
-
del self.__dict__["validated_trading_service"]
|
|
1070
1188
|
self.checks.delete()
|
|
1071
1189
|
|
|
1072
1190
|
def can_backtodraft(self):
|
|
@@ -1074,7 +1192,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1074
1192
|
|
|
1075
1193
|
@transition(
|
|
1076
1194
|
field=status,
|
|
1077
|
-
source=Status.
|
|
1195
|
+
source=Status.CONFIRMED,
|
|
1078
1196
|
target=Status.DRAFT,
|
|
1079
1197
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1080
1198
|
user.profile, portfolio=instance.portfolio
|
|
@@ -1082,7 +1200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1082
1200
|
custom={
|
|
1083
1201
|
"_transition_button": ActionButton(
|
|
1084
1202
|
method=RequestType.PATCH,
|
|
1085
|
-
identifiers=("wbportfolio:
|
|
1203
|
+
identifiers=("wbportfolio:order",),
|
|
1086
1204
|
icon=WBIcon.REGENERATE.icon,
|
|
1087
1205
|
key="revert",
|
|
1088
1206
|
label="Revert",
|
|
@@ -1093,8 +1211,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1093
1211
|
)
|
|
1094
1212
|
def revert(self, **kwargs):
|
|
1095
1213
|
self.approver = None
|
|
1096
|
-
with suppress(KeyError):
|
|
1097
|
-
del self.__dict__["validated_trading_service"]
|
|
1098
1214
|
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
1099
1215
|
is_estimated=True
|
|
1100
1216
|
) # we delete the existing portfolio as it has been reverted
|
|
@@ -1103,87 +1219,45 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1103
1219
|
errors = dict()
|
|
1104
1220
|
if not self.portfolio.can_be_rebalanced:
|
|
1105
1221
|
errors["portfolio"] = [
|
|
1106
|
-
|
|
1222
|
+
gettext_lazy(
|
|
1223
|
+
"The portfolio needs to be a model portfolio in order to revert this order proposal manually"
|
|
1224
|
+
)
|
|
1107
1225
|
]
|
|
1108
1226
|
return errors
|
|
1109
1227
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
@transition(
|
|
1142
|
-
field=status,
|
|
1143
|
-
source=Status.EXECUTION,
|
|
1144
|
-
target=Status.APPROVED,
|
|
1145
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1146
|
-
user.profile, portfolio=instance.portfolio
|
|
1147
|
-
)
|
|
1148
|
-
and instance.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT],
|
|
1149
|
-
custom={
|
|
1150
|
-
"_transition_button": ActionButton(
|
|
1151
|
-
method=RequestType.PATCH,
|
|
1152
|
-
identifiers=("wbportfolio:orderproposal",),
|
|
1153
|
-
icon=WBIcon.PREVIOUS.icon,
|
|
1154
|
-
key="cancelexecution",
|
|
1155
|
-
label="Cancel Execution",
|
|
1156
|
-
action_label="Cancel Execution",
|
|
1157
|
-
description_fields="<p>Cancel the current requested execution. Time sensitive operation.</p>",
|
|
1158
|
-
)
|
|
1159
|
-
},
|
|
1160
|
-
)
|
|
1161
|
-
def cancelexecution(self, **kwargs):
|
|
1162
|
-
warning = ""
|
|
1163
|
-
try:
|
|
1164
|
-
if cancel_rebalancing(self):
|
|
1165
|
-
self.execution_comment, self.execution_status_detail, self.execution_status = (
|
|
1166
|
-
"",
|
|
1167
|
-
"",
|
|
1168
|
-
ExecutionStatus.CANCELLED,
|
|
1169
|
-
)
|
|
1228
|
+
def apply_workflow(
|
|
1229
|
+
self,
|
|
1230
|
+
apply_automatically: bool = True,
|
|
1231
|
+
silent_exception: bool = False,
|
|
1232
|
+
force_reset_order: bool = False,
|
|
1233
|
+
**reset_order_kwargs,
|
|
1234
|
+
):
|
|
1235
|
+
# before, we need to save all positions in the builder first because effective weight depends on it
|
|
1236
|
+
self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
|
|
1237
|
+
if self.status == OrderProposal.Status.CONFIRMED:
|
|
1238
|
+
logger.info("Reverting order proposal ...")
|
|
1239
|
+
self.revert()
|
|
1240
|
+
if self.status == OrderProposal.Status.DRAFT:
|
|
1241
|
+
if (
|
|
1242
|
+
self.rebalancing_model or force_reset_order
|
|
1243
|
+
): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
|
|
1244
|
+
logger.info("Resetting orders ...")
|
|
1245
|
+
try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
|
|
1246
|
+
self.reset_orders(**reset_order_kwargs)
|
|
1247
|
+
except (ValidationError, DatabaseError) as e:
|
|
1248
|
+
self.status = OrderProposal.Status.FAILED
|
|
1249
|
+
if not silent_exception:
|
|
1250
|
+
raise ValidationError(e) from e
|
|
1251
|
+
return
|
|
1252
|
+
logger.info("Submitting order proposal ...")
|
|
1253
|
+
self.submit(pretrade_check=False)
|
|
1254
|
+
if apply_automatically:
|
|
1255
|
+
logger.info("Applying order proposal ...")
|
|
1256
|
+
if self.status == OrderProposal.Status.PENDING:
|
|
1257
|
+
self.approve(replay=False)
|
|
1170
1258
|
else:
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
warning = f"Could not cancel orders proposal {self}: {str(e)}"
|
|
1174
|
-
logger.error(warning)
|
|
1175
|
-
return warning
|
|
1176
|
-
|
|
1177
|
-
def can_cancelexecution(self):
|
|
1178
|
-
if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
|
|
1179
|
-
return {"execution_status": "Execution can only be cancelled if it is not already executed"}
|
|
1180
|
-
|
|
1181
|
-
def update_execution_status(self):
|
|
1182
|
-
try:
|
|
1183
|
-
self.execution_status, self.execution_status_detail = get_execution_status(self)
|
|
1184
|
-
self.save()
|
|
1185
|
-
except (RoutingException, ValueError) as e:
|
|
1186
|
-
logger.warning(f"Could not update rebalancing status: {str(e)}")
|
|
1259
|
+
self.apply()
|
|
1260
|
+
self.status = self.Status.CONFIRMED
|
|
1187
1261
|
|
|
1188
1262
|
# End FSM logics
|
|
1189
1263
|
|
|
@@ -1203,6 +1277,55 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1203
1277
|
def get_representation_label_key(cls) -> str:
|
|
1204
1278
|
return "{{_portfolio.name}} ({{trade_date}})"
|
|
1205
1279
|
|
|
1280
|
+
@classmethod
|
|
1281
|
+
def build(
|
|
1282
|
+
cls,
|
|
1283
|
+
trade_date: date,
|
|
1284
|
+
portfolio,
|
|
1285
|
+
target_portfolio: PortfolioDTO,
|
|
1286
|
+
creator: User | None = None,
|
|
1287
|
+
approve_automatically: bool = True,
|
|
1288
|
+
) -> Self:
|
|
1289
|
+
order_proposal, _ = OrderProposal.objects.update_or_create(
|
|
1290
|
+
portfolio=portfolio,
|
|
1291
|
+
trade_date=trade_date,
|
|
1292
|
+
defaults={"status": OrderProposal.Status.DRAFT, "creator": creator.profile if creator else None},
|
|
1293
|
+
)
|
|
1294
|
+
order_proposal.reset_orders(target_portfolio=target_portfolio)
|
|
1295
|
+
if approve_automatically:
|
|
1296
|
+
order_proposal.submit()
|
|
1297
|
+
order_proposal.approve(by=creator)
|
|
1298
|
+
if portfolio.can_be_rebalanced:
|
|
1299
|
+
order_proposal.apply()
|
|
1300
|
+
order_proposal.save()
|
|
1301
|
+
return order_proposal
|
|
1302
|
+
|
|
1303
|
+
def push_to_dependant_portfolios(
|
|
1304
|
+
self, only_portfolios: QuerySet[Portfolio] | None = None, **build_kwargs
|
|
1305
|
+
) -> list[Self]:
|
|
1306
|
+
order_proposals = []
|
|
1307
|
+
for rel in self.portfolio.get_model_portfolio_relationships(self.trade_date):
|
|
1308
|
+
existing_order_proposal = OrderProposal.objects.filter(
|
|
1309
|
+
portfolio=rel.portfolio, trade_date=self.trade_date
|
|
1310
|
+
).first()
|
|
1311
|
+
# we allow push only on existing draft order proposal
|
|
1312
|
+
dependency_portfolio = rel.dependency_portfolio
|
|
1313
|
+
if (
|
|
1314
|
+
(only_portfolios is None or rel.portfolio in only_portfolios)
|
|
1315
|
+
and (not existing_order_proposal or existing_order_proposal.status == OrderProposal.Status.DRAFT)
|
|
1316
|
+
and dependency_portfolio.assets.filter(date=self.trade_date).exists()
|
|
1317
|
+
):
|
|
1318
|
+
target_portfolio = dependency_portfolio._build_dto(self.trade_date)
|
|
1319
|
+
order_proposals.append(
|
|
1320
|
+
OrderProposal.build(self.trade_date, rel.portfolio, target_portfolio, **build_kwargs)
|
|
1321
|
+
)
|
|
1322
|
+
return order_proposals
|
|
1323
|
+
|
|
1324
|
+
def evaluate_pretrade_checks(self, asynchronously: bool = True):
|
|
1325
|
+
self.checks.all().delete()
|
|
1326
|
+
self.refresh_cash_position()
|
|
1327
|
+
self.evaluate_active_rules(self.trade_date, self.get_target_portfolio(), asynchronously=asynchronously)
|
|
1328
|
+
|
|
1206
1329
|
|
|
1207
1330
|
@receiver(post_save, sender="wbportfolio.OrderProposal")
|
|
1208
1331
|
def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kwargs):
|
|
@@ -1212,73 +1335,80 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
|
|
|
1212
1335
|
instance.invalidate_future_order_proposal()
|
|
1213
1336
|
|
|
1214
1337
|
|
|
1215
|
-
@shared_task(queue="
|
|
1338
|
+
@shared_task(queue="oms")
|
|
1216
1339
|
def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
1217
1340
|
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1218
1341
|
order_proposal.replay(**kwargs)
|
|
1219
1342
|
if user_id:
|
|
1343
|
+
body = f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.'
|
|
1220
1344
|
user = User.objects.get(id=user_id)
|
|
1345
|
+
if order_proposal.portfolio.builder.excluded_positions:
|
|
1346
|
+
excluded_quotes = []
|
|
1347
|
+
for batch in order_proposal.portfolio.builder.excluded_positions.values():
|
|
1348
|
+
for pos in batch:
|
|
1349
|
+
excluded_quotes.append(pos.underlying_instrument)
|
|
1350
|
+
body += "<p><strong>Note</strong></p><p>While replaying and drifting the portfolio, we excluded the positions from the following quotes because of missing price</p> <ul>"
|
|
1351
|
+
for excluded_quote in set(excluded_quotes):
|
|
1352
|
+
body += f"<li>{excluded_quote}</li>"
|
|
1353
|
+
body += "</ul>"
|
|
1354
|
+
order_proposal.portfolio.builder.clear()
|
|
1221
1355
|
send_notification(
|
|
1222
|
-
code="wbportfolio.portfolio.
|
|
1356
|
+
code="wbportfolio.portfolio.action_done",
|
|
1223
1357
|
title="Order Proposal Replay Completed",
|
|
1224
|
-
body=
|
|
1358
|
+
body=body,
|
|
1225
1359
|
user=user,
|
|
1226
1360
|
reverse_name="wbportfolio:portfolio-detail",
|
|
1227
1361
|
reverse_args=[order_proposal.portfolio.id],
|
|
1228
1362
|
)
|
|
1229
1363
|
|
|
1230
1364
|
|
|
1231
|
-
@shared_task(queue="
|
|
1232
|
-
def execute_orders_as_task(order_proposal_id: int):
|
|
1365
|
+
@shared_task(queue="oms")
|
|
1366
|
+
def execute_orders_as_task(order_proposal_id: int, prioritize_target_weight: bool = False, **kwargs):
|
|
1233
1367
|
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
status = ExecutionStatus.FAILED
|
|
1239
|
-
comment = str(e)
|
|
1240
|
-
order_proposal.execution_status = status
|
|
1241
|
-
order_proposal.execution_comment = comment
|
|
1242
|
-
order_proposal.save()
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
@shared_task(queue="portfolio")
|
|
1368
|
+
order_proposal.execute_orders()
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
@shared_task(queue="oms")
|
|
1246
1372
|
def push_model_change_as_task(
|
|
1247
1373
|
model_order_proposal_id: int,
|
|
1248
|
-
user_id: int,
|
|
1374
|
+
user_id: int | None = None,
|
|
1249
1375
|
only_for_portfolio_ids: list[int] | None = None,
|
|
1250
1376
|
approve_automatically: bool = False,
|
|
1251
1377
|
):
|
|
1252
1378
|
# not happy with that but we will keep it for the MVP lifecycle
|
|
1253
1379
|
model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1380
|
+
user = User.objects.get(id=user_id) if user_id else None
|
|
1381
|
+
params = dict(approve_automatically=approve_automatically, creator=user)
|
|
1382
|
+
only_portfolios = None
|
|
1383
|
+
if only_for_portfolio_ids:
|
|
1384
|
+
only_portfolios = Portfolio.objects.filter(id__in=only_for_portfolio_ids)
|
|
1257
1385
|
|
|
1258
|
-
|
|
1259
|
-
class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
|
|
1260
|
-
)
|
|
1386
|
+
order_proposals = model_order_proposal.push_to_dependant_portfolios(only_portfolios=only_portfolios, **params)
|
|
1261
1387
|
product_html_list = "<ul>\n"
|
|
1262
|
-
for
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
portfolio=rel.portfolio, trade_date=trade_date, defaults={"rebalancing_model": model_rebalancing}
|
|
1266
|
-
)
|
|
1267
|
-
order_proposal.reset_orders()
|
|
1268
|
-
product_html_list += f"<li>{rel.portfolio}</li>\n"
|
|
1269
|
-
if approve_automatically:
|
|
1270
|
-
order_proposal.submit()
|
|
1271
|
-
order_proposal.approve(by=user)
|
|
1272
|
-
order_proposal.save()
|
|
1388
|
+
for order_proposal in order_proposals:
|
|
1389
|
+
product_html_list += f"<li>{order_proposal.portfolio}</li>\n"
|
|
1390
|
+
|
|
1273
1391
|
product_html_list += "</ul>"
|
|
1392
|
+
if user:
|
|
1393
|
+
send_notification(
|
|
1394
|
+
code="wbportfolio.order_proposal.push_model_changes",
|
|
1395
|
+
title="Portfolio Model changes are pushed to dependant portfolios",
|
|
1396
|
+
body=f"""
|
|
1397
|
+
<p>The latest updates to the portfolio model <strong>{model_order_proposal.portfolio}</strong> have been successfully applied to the associated portfolios, and corresponding orders have been created.</p>
|
|
1398
|
+
<p>To proceed with executing these orders, please review the following related portfolios: </p>
|
|
1399
|
+
{product_html_list}
|
|
1400
|
+
""",
|
|
1401
|
+
user=user,
|
|
1402
|
+
)
|
|
1274
1403
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
)
|
|
1404
|
+
|
|
1405
|
+
@receiver(investable_universe_updated, sender="wbfdm.Instrument")
|
|
1406
|
+
def update_exante_order_proposal_returns(*args, end_date: date | None = None, **kwargs):
|
|
1407
|
+
for op in OrderProposal.objects.filter(trade_date__gte=end_date):
|
|
1408
|
+
op.refresh_returns()
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
@receiver(pre_delete, sender=OrderProposal)
|
|
1412
|
+
def post_delete_adjustment(sender, instance: OrderProposal, **kwargs):
|
|
1413
|
+
for check in instance.checks.all():
|
|
1414
|
+
check.delete()
|