wbportfolio 1.54.23__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/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- 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/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/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/builder.py +70 -25
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +510 -161
- wbportfolio/models/orders/orders.py +20 -10
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +76 -41
- 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 -0
- 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 +23 -3
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/viewsets/charts/assets.py +4 -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 +56 -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 +45 -6
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -26,6 +26,7 @@ from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
|
26
26
|
from wbcore.contrib.authentication.models import User
|
|
27
27
|
from wbcore.contrib.icons import WBIcon
|
|
28
28
|
from wbcore.contrib.notifications.dispatch import send_notification
|
|
29
|
+
from wbcore.contrib.notifications.utils import create_notification_type
|
|
29
30
|
from wbcore.enums import RequestType
|
|
30
31
|
from wbcore.metadata.configs.buttons import ActionButton
|
|
31
32
|
from wbcore.models import WBModel
|
|
@@ -36,10 +37,14 @@ from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
|
36
37
|
from wbportfolio.models.asset import AssetPosition
|
|
37
38
|
from wbportfolio.models.roles import PortfolioRole
|
|
38
39
|
from wbportfolio.pms.trading import TradingService
|
|
40
|
+
from wbportfolio.pms.typing import Order as OrderDTO
|
|
39
41
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
40
42
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
41
43
|
|
|
44
|
+
from ...order_routing import ExecutionStatus, RoutingException
|
|
45
|
+
from ...order_routing.adapters import BaseCustodianAdapter
|
|
42
46
|
from .orders import Order
|
|
47
|
+
from .routing import cancel_rebalancing, execute_orders, get_execution_status
|
|
43
48
|
|
|
44
49
|
logger = logging.getLogger("pms")
|
|
45
50
|
|
|
@@ -51,9 +56,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
51
56
|
|
|
52
57
|
class Status(models.TextChoices):
|
|
53
58
|
DRAFT = "DRAFT", "Draft"
|
|
54
|
-
|
|
59
|
+
PENDING = "PENDING", "Pending"
|
|
55
60
|
APPROVED = "APPROVED", "Approved"
|
|
56
61
|
DENIED = "DENIED", "Denied"
|
|
62
|
+
APPLIED = "APPLIED", "Applied"
|
|
63
|
+
EXECUTION = "EXECUTION", "Execution"
|
|
57
64
|
FAILED = "FAILED", "Failed"
|
|
58
65
|
|
|
59
66
|
comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
|
|
@@ -78,6 +85,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
78
85
|
on_delete=models.PROTECT,
|
|
79
86
|
verbose_name="Owner",
|
|
80
87
|
)
|
|
88
|
+
approver = models.ForeignKey(
|
|
89
|
+
"directory.Person",
|
|
90
|
+
blank=True,
|
|
91
|
+
null=True,
|
|
92
|
+
related_name="approver_order_proposals",
|
|
93
|
+
on_delete=models.PROTECT,
|
|
94
|
+
verbose_name="Approver",
|
|
95
|
+
)
|
|
81
96
|
min_order_value = models.IntegerField(
|
|
82
97
|
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
83
98
|
)
|
|
@@ -88,6 +103,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
88
103
|
verbose_name="Total Cash Weight",
|
|
89
104
|
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%.",
|
|
90
105
|
)
|
|
106
|
+
total_effective_portfolio_contribution = models.DecimalField(
|
|
107
|
+
default=Decimal("1"),
|
|
108
|
+
max_digits=Order.ORDER_WEIGHTING_PRECISION * 2 + 3,
|
|
109
|
+
decimal_places=Order.ORDER_WEIGHTING_PRECISION * 2,
|
|
110
|
+
)
|
|
111
|
+
execution_status = models.CharField(
|
|
112
|
+
blank=True, default="", choices=ExecutionStatus.choices, verbose_name="Execution Status"
|
|
113
|
+
)
|
|
114
|
+
execution_status_detail = models.CharField(blank=True, default="", verbose_name="Execution Status Detail")
|
|
115
|
+
execution_comment = models.CharField(blank=True, default="", verbose_name="Execution Comment")
|
|
91
116
|
|
|
92
117
|
class Meta:
|
|
93
118
|
verbose_name = "Order Proposal"
|
|
@@ -99,6 +124,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
99
124
|
),
|
|
100
125
|
]
|
|
101
126
|
|
|
127
|
+
notification_types = [
|
|
128
|
+
create_notification_type(
|
|
129
|
+
"wbportfolio.order_proposal.push_model_changes",
|
|
130
|
+
"Push Model Changes",
|
|
131
|
+
"Sends a notification when a the change/orders are pushed to modeled after portfolios",
|
|
132
|
+
True,
|
|
133
|
+
True,
|
|
134
|
+
True,
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
|
|
102
138
|
def save(self, *args, **kwargs):
|
|
103
139
|
# 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
|
|
104
140
|
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
@@ -141,6 +177,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
141
177
|
except AssetPosition.DoesNotExist:
|
|
142
178
|
return self.value_date
|
|
143
179
|
|
|
180
|
+
@cached_property
|
|
181
|
+
def custodian_adapter(self) -> BaseCustodianAdapter | None:
|
|
182
|
+
try:
|
|
183
|
+
return self.portfolio.get_authenticated_custodian_adapter(raise_exception=True)
|
|
184
|
+
except ValueError as e:
|
|
185
|
+
logger.warning("Error while instantiating custodian adapter: %s", e)
|
|
186
|
+
|
|
144
187
|
@cached_property
|
|
145
188
|
def value_date(self) -> date:
|
|
146
189
|
return (self.trade_date - BDay(1)).date()
|
|
@@ -148,7 +191,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
148
191
|
@property
|
|
149
192
|
def previous_order_proposal(self) -> SelfOrderProposal | None:
|
|
150
193
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
151
|
-
trade_date__lt=self.trade_date, status=OrderProposal.Status.
|
|
194
|
+
trade_date__lt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
152
195
|
)
|
|
153
196
|
if future_proposals.exists():
|
|
154
197
|
return future_proposals.latest("trade_date")
|
|
@@ -157,7 +200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
157
200
|
@property
|
|
158
201
|
def next_order_proposal(self) -> SelfOrderProposal | None:
|
|
159
202
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
160
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
203
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
161
204
|
)
|
|
162
205
|
if future_proposals.exists():
|
|
163
206
|
return future_proposals.earliest("trade_date")
|
|
@@ -165,16 +208,34 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
165
208
|
|
|
166
209
|
@property
|
|
167
210
|
def cash_component(self) -> Cash:
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
211
|
+
return self.portfolio.cash_component
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def total_effective_portfolio_weight(self) -> Decimal:
|
|
215
|
+
return Decimal("1.0")
|
|
171
216
|
|
|
172
217
|
@property
|
|
173
218
|
def total_expected_target_weight(self) -> Decimal:
|
|
174
|
-
return
|
|
219
|
+
return self.total_effective_portfolio_weight - self.total_cash_weight
|
|
220
|
+
|
|
221
|
+
@cached_property
|
|
222
|
+
def total_effective_portfolio_cash_weight(self) -> Decimal:
|
|
223
|
+
return self.portfolio.assets.filter(
|
|
224
|
+
models.Q(date=self.last_effective_date)
|
|
225
|
+
& (models.Q(underlying_quote__is_cash=True) | models.Q(underlying_quote__is_cash_equivalent=True))
|
|
226
|
+
).aggregate(Sum("weighting"))["weighting__sum"] or Decimal("0")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def execution_instruction(self):
|
|
230
|
+
# TODO make this dynamically configurable
|
|
231
|
+
return "MARKET_ON_CLOSE"
|
|
175
232
|
|
|
176
233
|
def get_orders(self):
|
|
177
|
-
|
|
234
|
+
# 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)
|
|
235
|
+
# so the subquery returns the previous position (probably USD), but is missing the other cash aggregation. We need to find a way to handle that properly
|
|
236
|
+
|
|
237
|
+
orders = self.orders.all().annotate(
|
|
238
|
+
total_effective_portfolio_contribution=Value(self.total_effective_portfolio_contribution),
|
|
178
239
|
last_effective_date=Subquery(
|
|
179
240
|
AssetPosition.unannotated_objects.filter(
|
|
180
241
|
date__lt=OuterRef("value_date"),
|
|
@@ -183,27 +244,33 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
183
244
|
.order_by("-date")
|
|
184
245
|
.values("date")[:1]
|
|
185
246
|
),
|
|
186
|
-
previous_weight=
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
247
|
+
previous_weight=models.Case(
|
|
248
|
+
models.When(
|
|
249
|
+
underlying_instrument__is_cash=False,
|
|
250
|
+
then=Coalesce(
|
|
251
|
+
Subquery(
|
|
252
|
+
AssetPosition.unannotated_objects.filter(
|
|
253
|
+
underlying_quote=OuterRef("underlying_instrument"),
|
|
254
|
+
date=OuterRef("last_effective_date"),
|
|
255
|
+
portfolio=OuterRef("portfolio"),
|
|
256
|
+
)
|
|
257
|
+
.values("portfolio")
|
|
258
|
+
.annotate(s=Sum("weighting"))
|
|
259
|
+
.values("s")[:1]
|
|
260
|
+
),
|
|
261
|
+
Decimal(0),
|
|
262
|
+
),
|
|
196
263
|
),
|
|
197
|
-
|
|
264
|
+
default=Value(self.total_effective_portfolio_cash_weight),
|
|
198
265
|
),
|
|
199
266
|
contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
|
|
200
|
-
)
|
|
201
|
-
portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
|
|
202
|
-
orders = base_qs.annotate(
|
|
203
267
|
effective_weight=Round(
|
|
204
|
-
|
|
268
|
+
models.Case(
|
|
269
|
+
models.When(total_effective_portfolio_contribution=Value(Decimal("0")), then=Value(Decimal("0"))),
|
|
270
|
+
default=F("contribution") / F("total_effective_portfolio_contribution"),
|
|
271
|
+
),
|
|
272
|
+
precision=Order.ORDER_WEIGHTING_PRECISION,
|
|
205
273
|
),
|
|
206
|
-
tmp_effective_weight=F("contribution") / Value(portfolio_contribution),
|
|
207
274
|
target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
|
|
208
275
|
effective_shares=Coalesce(
|
|
209
276
|
Subquery(
|
|
@@ -220,30 +287,70 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
220
287
|
),
|
|
221
288
|
target_shares=F("effective_shares") + F("shares"),
|
|
222
289
|
)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
models.
|
|
230
|
-
|
|
290
|
+
|
|
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"),
|
|
231
302
|
),
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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"),
|
|
237
309
|
),
|
|
238
|
-
|
|
239
|
-
),
|
|
240
|
-
)
|
|
310
|
+
)
|
|
241
311
|
return orders.annotate(
|
|
242
312
|
has_warnings=models.Case(
|
|
243
|
-
models.When(
|
|
313
|
+
models.When(
|
|
314
|
+
(models.Q(price=0) & ~models.Q(target_weight=0)) | models.Q(target_weight__lt=0), then=Value(True)
|
|
315
|
+
),
|
|
316
|
+
default=Value(False),
|
|
244
317
|
),
|
|
245
318
|
)
|
|
246
319
|
|
|
320
|
+
def prepare_orders_for_execution(self) -> list[OrderDTO]:
|
|
321
|
+
executable_orders = []
|
|
322
|
+
for order in self.get_orders().select_related("underlying_instrument"):
|
|
323
|
+
instrument = order.underlying_instrument
|
|
324
|
+
# we support only the instrument type provided by the Order DTO class
|
|
325
|
+
asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
|
|
326
|
+
try:
|
|
327
|
+
if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
|
|
328
|
+
executable_orders.append(
|
|
329
|
+
OrderDTO(
|
|
330
|
+
id=order.id,
|
|
331
|
+
asset_class=OrderDTO.AssetType[asset_class],
|
|
332
|
+
weighting=float(order.weighting),
|
|
333
|
+
target_weight=float(order.target_weight),
|
|
334
|
+
trade_date=order.value_date,
|
|
335
|
+
shares=float(order.shares) if order.shares is not None else None,
|
|
336
|
+
target_shares=float(order.target_shares) if order.target_shares is not None else None,
|
|
337
|
+
refinitiv_identifier_code=instrument.refinitiv_identifier_code,
|
|
338
|
+
bloomberg_ticker=instrument.ticker,
|
|
339
|
+
sedol=instrument.sedol,
|
|
340
|
+
execution_instruction=self.execution_instruction,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
order.execution_confirmed = False
|
|
345
|
+
order.execution_comment = "Underlying instrument does not have a valid identifier."
|
|
346
|
+
order.save()
|
|
347
|
+
except AttributeError:
|
|
348
|
+
order.execution_confirmed = False
|
|
349
|
+
order.execution_comment = f"Unsupported asset class {asset_class.title()}."
|
|
350
|
+
order.save()
|
|
351
|
+
|
|
352
|
+
return executable_orders
|
|
353
|
+
|
|
247
354
|
def __str__(self) -> str:
|
|
248
355
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
249
356
|
|
|
@@ -251,56 +358,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
251
358
|
self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
|
|
252
359
|
) -> PortfolioDTO:
|
|
253
360
|
"""
|
|
254
|
-
|
|
361
|
+
Converts the internal portfolio state and pending orders into a PortfolioDTO.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
use_effective: Use effective share quantities for positions if True.
|
|
365
|
+
with_cash: Include a cash position in the result if True.
|
|
366
|
+
use_desired_target_weight: Use desired target weights from orders if True.
|
|
367
|
+
|
|
255
368
|
Returns:
|
|
256
|
-
|
|
369
|
+
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
257
370
|
"""
|
|
258
371
|
portfolio = {}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
372
|
+
|
|
373
|
+
# 1. Gather all non-cash, positively weighted assets from the existing portfolio.
|
|
374
|
+
for asset in self.portfolio.assets.filter(
|
|
375
|
+
date=self.last_effective_date,
|
|
376
|
+
# underlying_quote__is_cash=False,
|
|
377
|
+
# underlying_quote__is_cash_equivalent=False,
|
|
378
|
+
weighting__gt=0,
|
|
379
|
+
):
|
|
380
|
+
portfolio[asset.underlying_quote] = {
|
|
381
|
+
"shares": asset._shares,
|
|
382
|
+
"weighting": asset.weighting,
|
|
383
|
+
"delta_weight": Decimal("0"),
|
|
384
|
+
"price": asset._price,
|
|
385
|
+
"currency_fx_rate": asset._currency_fx_rate,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# 2. Add or update non-cash orders, possibly overriding weights.
|
|
389
|
+
for order in self.get_orders().filter(
|
|
390
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
391
|
+
):
|
|
269
392
|
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
270
|
-
delta_weight =
|
|
393
|
+
delta_weight = Decimal("0")
|
|
394
|
+
weighting = order.desired_target_weight
|
|
271
395
|
else:
|
|
272
396
|
delta_weight = order.weighting
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
397
|
+
weighting = order._previous_weight
|
|
398
|
+
|
|
399
|
+
portfolio[order.underlying_instrument] = {
|
|
400
|
+
"weighting": weighting,
|
|
401
|
+
"delta_weight": delta_weight,
|
|
402
|
+
"shares": order._target_shares if not use_effective else order._effective_shares,
|
|
403
|
+
"price": order.price,
|
|
404
|
+
"currency_fx_rate": order.currency_fx_rate,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# 3. Prepare a mapping from instrument IDs to weights for analytic calculations.
|
|
408
|
+
previous_weights = {instrument.id: float(info["weighting"]) for instrument, info in portfolio.items()}
|
|
409
|
+
|
|
410
|
+
# 4. Attempt to fetch analytic returns and portfolio contribution. Default on error.
|
|
281
411
|
try:
|
|
282
|
-
last_returns,
|
|
412
|
+
last_returns, contribution = self.portfolio.get_analytic_portfolio(
|
|
283
413
|
self.value_date, weights=previous_weights, use_dl=True
|
|
284
414
|
).get_contributions()
|
|
285
415
|
last_returns = last_returns.to_dict()
|
|
286
416
|
except ValueError:
|
|
287
|
-
last_returns,
|
|
417
|
+
last_returns, contribution = {}, 1
|
|
418
|
+
|
|
288
419
|
positions = []
|
|
289
420
|
total_weighting = Decimal("0")
|
|
421
|
+
|
|
422
|
+
# 5. Build PositionDTO objects for all instruments.
|
|
290
423
|
for instrument, row in portfolio.items():
|
|
291
424
|
weighting = row["weighting"]
|
|
292
425
|
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
293
426
|
|
|
294
|
-
if
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
427
|
+
# Optionally apply drift to weightings if required
|
|
428
|
+
if not use_effective and not use_desired_target_weight:
|
|
429
|
+
if contribution:
|
|
430
|
+
drifted_weight = round(
|
|
431
|
+
weighting * (daily_return + Decimal("1")) / Decimal(contribution),
|
|
298
432
|
Order.ORDER_WEIGHTING_PRECISION,
|
|
299
433
|
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
434
|
+
else:
|
|
435
|
+
drifted_weight = weighting
|
|
303
436
|
weighting = drifted_weight + row["delta_weight"]
|
|
437
|
+
|
|
438
|
+
# Assemble the position object
|
|
304
439
|
positions.append(
|
|
305
440
|
PositionDTO(
|
|
306
441
|
underlying_instrument=instrument.id,
|
|
@@ -316,9 +451,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
316
451
|
)
|
|
317
452
|
)
|
|
318
453
|
total_weighting += weighting
|
|
319
|
-
|
|
454
|
+
|
|
455
|
+
# 6. Optionally include a cash position to balance the total weighting.
|
|
456
|
+
if (
|
|
457
|
+
portfolio
|
|
458
|
+
and with_cash
|
|
459
|
+
and total_weighting
|
|
460
|
+
and self.total_effective_portfolio_weight
|
|
461
|
+
and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
|
|
462
|
+
):
|
|
320
463
|
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
321
464
|
positions.append(cash_position._build_dto())
|
|
465
|
+
|
|
322
466
|
return PortfolioDTO(positions)
|
|
323
467
|
|
|
324
468
|
# Start tools methods
|
|
@@ -374,10 +518,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
374
518
|
|
|
375
519
|
def fix_quantization(self):
|
|
376
520
|
if self.orders.exists():
|
|
377
|
-
|
|
521
|
+
orders = self.get_orders()
|
|
522
|
+
t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
378
523
|
# 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
|
|
379
524
|
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
380
|
-
biggest_order =
|
|
525
|
+
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
|
|
381
526
|
biggest_order.weighting -= quantize_error
|
|
382
527
|
biggest_order.save()
|
|
383
528
|
|
|
@@ -407,6 +552,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
407
552
|
"""
|
|
408
553
|
if self.rebalancing_model:
|
|
409
554
|
self.orders.all().delete()
|
|
555
|
+
else:
|
|
556
|
+
self.orders.filter(underlying_instrument__is_cash=True).delete()
|
|
410
557
|
# delete all existing orders
|
|
411
558
|
# Get effective and target portfolio
|
|
412
559
|
if not target_portfolio:
|
|
@@ -426,6 +573,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
426
573
|
else:
|
|
427
574
|
orders = service.trades_batch.trades_map.values()
|
|
428
575
|
|
|
576
|
+
objs = []
|
|
429
577
|
for order_dto in orders:
|
|
430
578
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
431
579
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -448,30 +596,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
448
596
|
daily_return=daily_return,
|
|
449
597
|
currency_fx_rate=currency_fx_rate,
|
|
450
598
|
)
|
|
451
|
-
order.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
order.
|
|
599
|
+
order.desired_target_weight = order_dto.target_weight
|
|
600
|
+
order.order_type = Order.get_type(
|
|
601
|
+
weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
|
|
602
|
+
)
|
|
603
|
+
order.pre_save()
|
|
604
|
+
# # if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
605
|
+
# if not order.price and order.weighting > 0:
|
|
606
|
+
# order.price = Decimal("0.0")
|
|
607
|
+
# order.weighting = -order_dto.effective_weight
|
|
608
|
+
objs.append(order)
|
|
609
|
+
Order.objects.bulk_create(
|
|
610
|
+
objs,
|
|
611
|
+
update_fields=[
|
|
612
|
+
"value_date",
|
|
613
|
+
"weighting",
|
|
614
|
+
"daily_return",
|
|
615
|
+
"currency_fx_rate",
|
|
616
|
+
"order_type",
|
|
617
|
+
"portfolio",
|
|
618
|
+
"price",
|
|
619
|
+
"price_gross",
|
|
620
|
+
"desired_target_weight",
|
|
621
|
+
# "shares"
|
|
622
|
+
],
|
|
623
|
+
unique_fields=["order_proposal", "underlying_instrument"],
|
|
624
|
+
update_conflicts=True,
|
|
625
|
+
batch_size=1000,
|
|
626
|
+
)
|
|
458
627
|
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
459
|
-
self.get_orders().
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
628
|
+
self.get_orders().exclude(underlying_instrument__is_cash=True).filter(
|
|
629
|
+
target_weight=0, effective_weight=0
|
|
630
|
+
).delete()
|
|
631
|
+
self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
|
|
632
|
+
# self.fix_quantization()
|
|
633
|
+
self.total_effective_portfolio_contribution = effective_portfolio.portfolio_contribution
|
|
634
|
+
self.save()
|
|
466
635
|
|
|
467
|
-
def
|
|
636
|
+
def apply_workflow(
|
|
468
637
|
self,
|
|
469
|
-
|
|
638
|
+
apply_automatically: bool = True,
|
|
470
639
|
silent_exception: bool = False,
|
|
471
640
|
force_reset_order: bool = False,
|
|
472
641
|
**reset_order_kwargs,
|
|
473
642
|
):
|
|
474
|
-
|
|
643
|
+
# before, we need to save all positions in the builder first because effective weight depends on it
|
|
644
|
+
self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
|
|
645
|
+
if self.status == OrderProposal.Status.APPLIED:
|
|
475
646
|
logger.info("Reverting order proposal ...")
|
|
476
647
|
self.revert()
|
|
477
648
|
if self.status == OrderProposal.Status.DRAFT:
|
|
@@ -488,26 +659,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
488
659
|
return
|
|
489
660
|
logger.info("Submitting order proposal ...")
|
|
490
661
|
self.submit()
|
|
491
|
-
if self.status == OrderProposal.Status.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
662
|
+
if self.status == OrderProposal.Status.PENDING:
|
|
663
|
+
self.approve()
|
|
664
|
+
if apply_automatically and self.portfolio.can_be_rebalanced:
|
|
665
|
+
logger.info("Applying order proposal ...")
|
|
666
|
+
self.apply(replay=False)
|
|
495
667
|
|
|
496
|
-
def replay(
|
|
668
|
+
def replay(
|
|
669
|
+
self,
|
|
670
|
+
broadcast_changes_at_date: bool = True,
|
|
671
|
+
reapply_order_proposal: bool = False,
|
|
672
|
+
synchronous: bool = False,
|
|
673
|
+
**reset_order_kwargs,
|
|
674
|
+
):
|
|
497
675
|
last_order_proposal = self
|
|
498
676
|
last_order_proposal_created = False
|
|
499
|
-
self.portfolio.load_builder_returns(self.trade_date, date.today())
|
|
500
|
-
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.
|
|
677
|
+
self.portfolio.load_builder_returns((self.trade_date - BDay(3)).date(), date.today())
|
|
678
|
+
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPLIED:
|
|
501
679
|
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
502
680
|
if not last_order_proposal_created:
|
|
503
|
-
if reapply_order_proposal:
|
|
681
|
+
if reapply_order_proposal or last_order_proposal.rebalancing_model:
|
|
504
682
|
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
505
|
-
last_order_proposal.
|
|
683
|
+
last_order_proposal.apply_workflow(
|
|
684
|
+
silent_exception=True, force_reset_order=True, **reset_order_kwargs
|
|
685
|
+
)
|
|
506
686
|
last_order_proposal.save()
|
|
507
687
|
else:
|
|
508
688
|
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
509
|
-
last_order_proposal.reset_orders()
|
|
510
|
-
if last_order_proposal.status != OrderProposal.Status.
|
|
689
|
+
last_order_proposal.reset_orders(**reset_order_kwargs)
|
|
690
|
+
if last_order_proposal.status != OrderProposal.Status.APPLIED:
|
|
511
691
|
break
|
|
512
692
|
next_order_proposal = last_order_proposal.next_order_proposal
|
|
513
693
|
if next_order_proposal:
|
|
@@ -546,8 +726,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
546
726
|
else:
|
|
547
727
|
last_order_proposal_created = False
|
|
548
728
|
last_order_proposal = next_order_proposal
|
|
549
|
-
|
|
550
|
-
|
|
729
|
+
self.portfolio.builder.schedule_change_at_dates(
|
|
730
|
+
synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
|
|
731
|
+
)
|
|
551
732
|
|
|
552
733
|
def invalidate_future_order_proposal(self):
|
|
553
734
|
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
@@ -555,7 +736,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
555
736
|
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
556
737
|
).delete()
|
|
557
738
|
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
558
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
739
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
559
740
|
):
|
|
560
741
|
future_order_proposal.revert()
|
|
561
742
|
future_order_proposal.save()
|
|
@@ -577,6 +758,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
577
758
|
"""
|
|
578
759
|
# Retrieve the price of the underlying quote on the order date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
579
760
|
|
|
761
|
+
# if an order exists for this estimation and the target weight is 0, then we return the inverse of the effective shares
|
|
762
|
+
with suppress(Order.DoesNotExist):
|
|
763
|
+
order = self.get_orders().get(underlying_instrument=underlying_quote)
|
|
764
|
+
if order.target_weight == 0:
|
|
765
|
+
return -order.effective_shares
|
|
580
766
|
# Calculate the order's total value in the portfolio's currency
|
|
581
767
|
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
582
768
|
|
|
@@ -614,9 +800,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
614
800
|
# Retrieve orders with base information
|
|
615
801
|
orders = self.get_orders()
|
|
616
802
|
# Calculate the total target weight of all orders
|
|
617
|
-
total_target_weight = orders.
|
|
618
|
-
|
|
619
|
-
)["s"] or Decimal(0)
|
|
803
|
+
total_target_weight = orders.filter(
|
|
804
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
805
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
620
806
|
if target_cash_weight is None:
|
|
621
807
|
target_cash_weight = Decimal("1") - total_target_weight
|
|
622
808
|
|
|
@@ -657,7 +843,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
657
843
|
@transition(
|
|
658
844
|
field=status,
|
|
659
845
|
source=Status.DRAFT,
|
|
660
|
-
target=Status.
|
|
846
|
+
target=Status.PENDING,
|
|
661
847
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
662
848
|
user.profile, portfolio=instance.portfolio
|
|
663
849
|
),
|
|
@@ -720,18 +906,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
720
906
|
del self.__dict__["validated_trading_service"]
|
|
721
907
|
return errors
|
|
722
908
|
|
|
723
|
-
@property
|
|
724
|
-
def can_be_approved_or_denied(self):
|
|
725
|
-
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
726
|
-
|
|
727
909
|
@transition(
|
|
728
910
|
field=status,
|
|
729
|
-
source=Status.
|
|
911
|
+
source=Status.PENDING,
|
|
730
912
|
target=Status.APPROVED,
|
|
731
913
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
732
914
|
user.profile, portfolio=instance.portfolio
|
|
733
915
|
)
|
|
734
|
-
and instance.
|
|
916
|
+
and not instance.has_non_successful_checks,
|
|
735
917
|
custom={
|
|
736
918
|
"_transition_button": ActionButton(
|
|
737
919
|
method=RequestType.PATCH,
|
|
@@ -744,7 +926,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
744
926
|
)
|
|
745
927
|
},
|
|
746
928
|
)
|
|
747
|
-
def approve(self, by=None, description=None,
|
|
929
|
+
def approve(self, by=None, description=None, **kwargs):
|
|
930
|
+
if by:
|
|
931
|
+
self.approver = getattr(by, "profile", None)
|
|
932
|
+
elif not self.approver:
|
|
933
|
+
self.approver = self.creator
|
|
934
|
+
|
|
935
|
+
def can_approve(self):
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
@transition(
|
|
939
|
+
field=status,
|
|
940
|
+
source=Status.PENDING,
|
|
941
|
+
target=Status.DENIED,
|
|
942
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
943
|
+
user.profile, portfolio=instance.portfolio
|
|
944
|
+
)
|
|
945
|
+
and not instance.has_non_successful_checks,
|
|
946
|
+
custom={
|
|
947
|
+
"_transition_button": ActionButton(
|
|
948
|
+
method=RequestType.PATCH,
|
|
949
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
950
|
+
icon=WBIcon.DENY.icon,
|
|
951
|
+
key="deny",
|
|
952
|
+
label="Deny",
|
|
953
|
+
action_label="Deny",
|
|
954
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
955
|
+
)
|
|
956
|
+
},
|
|
957
|
+
)
|
|
958
|
+
def deny(self, by=None, description=None, **kwargs):
|
|
959
|
+
pass
|
|
960
|
+
|
|
961
|
+
def can_deny(self):
|
|
962
|
+
pass
|
|
963
|
+
|
|
964
|
+
@property
|
|
965
|
+
def can_be_applied(self):
|
|
966
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
967
|
+
|
|
968
|
+
@transition(
|
|
969
|
+
field=status,
|
|
970
|
+
source=Status.APPROVED,
|
|
971
|
+
target=Status.APPLIED,
|
|
972
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
973
|
+
user.profile, portfolio=instance.portfolio
|
|
974
|
+
)
|
|
975
|
+
and instance.can_be_applied,
|
|
976
|
+
custom={
|
|
977
|
+
"_transition_button": ActionButton(
|
|
978
|
+
method=RequestType.PATCH,
|
|
979
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
980
|
+
icon=WBIcon.SAVE.icon,
|
|
981
|
+
key="apply",
|
|
982
|
+
label="Apply",
|
|
983
|
+
action_label="Apply",
|
|
984
|
+
)
|
|
985
|
+
},
|
|
986
|
+
)
|
|
987
|
+
def apply(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
748
988
|
# We validate order which will create or update the initial asset positions
|
|
749
989
|
if not self.portfolio.can_be_rebalanced:
|
|
750
990
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -755,39 +995,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
755
995
|
for order in self.get_orders():
|
|
756
996
|
with suppress(ValueError):
|
|
757
997
|
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
758
|
-
if
|
|
998
|
+
if (
|
|
999
|
+
order.underlying_instrument != estimated_cash_position.underlying_quote
|
|
1000
|
+
and order._target_weight > 0
|
|
1001
|
+
):
|
|
759
1002
|
assets[order.underlying_instrument.id] = order._target_weight
|
|
760
1003
|
|
|
761
1004
|
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
762
|
-
if estimated_cash_position.weighting and len(assets) > 0:
|
|
1005
|
+
if estimated_cash_position.weighting > 0 and len(assets) > 0:
|
|
763
1006
|
warnings.append(
|
|
764
1007
|
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
765
1008
|
)
|
|
766
1009
|
assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
|
|
767
|
-
|
|
768
1010
|
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
769
|
-
force_save=True, is_estimated=False
|
|
1011
|
+
force_save=True, is_estimated=False, delete_leftovers=True
|
|
770
1012
|
)
|
|
771
1013
|
if replay and self.portfolio.is_manageable:
|
|
772
|
-
replay_as_task.delay(
|
|
1014
|
+
replay_as_task.delay(
|
|
1015
|
+
self.id, user_id=by.id if by else None, broadcast_changes_at_date=False, reapply_order_proposal=True
|
|
1016
|
+
)
|
|
1017
|
+
if by:
|
|
1018
|
+
self.approver = by.profile
|
|
773
1019
|
return warnings
|
|
774
1020
|
|
|
775
|
-
def
|
|
1021
|
+
def can_apply(self):
|
|
776
1022
|
errors = dict()
|
|
777
1023
|
orders = self.get_orders()
|
|
778
1024
|
if not self.portfolio.can_be_rebalanced:
|
|
779
1025
|
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
780
1026
|
if not orders.exists():
|
|
781
1027
|
errors["non_field_errors"] = [
|
|
782
|
-
_("At least one order needs to be submitted to be able to
|
|
1028
|
+
_("At least one order needs to be submitted to be able to apply this proposal")
|
|
783
1029
|
]
|
|
784
1030
|
if not self.portfolio.can_be_rebalanced:
|
|
785
1031
|
errors["portfolio"] = [
|
|
786
|
-
[_("The portfolio needs to be a model portfolio in order to
|
|
1032
|
+
[_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
|
|
787
1033
|
]
|
|
788
1034
|
if self.has_non_successful_checks:
|
|
789
1035
|
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
790
|
-
if orders.filter(has_warnings=True).
|
|
1036
|
+
if orders.filter(has_warnings=True).filter(
|
|
1037
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
1038
|
+
):
|
|
791
1039
|
errors["non_field_errors"] = [
|
|
792
1040
|
_("There is warning that needs to be addresses on the orders before approval.")
|
|
793
1041
|
]
|
|
@@ -795,40 +1043,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
795
1043
|
|
|
796
1044
|
@transition(
|
|
797
1045
|
field=status,
|
|
798
|
-
source=Status.
|
|
799
|
-
target=Status.DENIED,
|
|
800
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
801
|
-
user.profile, portfolio=instance.portfolio
|
|
802
|
-
)
|
|
803
|
-
and instance.can_be_approved_or_denied,
|
|
804
|
-
custom={
|
|
805
|
-
"_transition_button": ActionButton(
|
|
806
|
-
method=RequestType.PATCH,
|
|
807
|
-
identifiers=("wbportfolio:orderproposal",),
|
|
808
|
-
icon=WBIcon.DENY.icon,
|
|
809
|
-
key="deny",
|
|
810
|
-
label="Deny",
|
|
811
|
-
action_label="Deny",
|
|
812
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
813
|
-
)
|
|
814
|
-
},
|
|
815
|
-
)
|
|
816
|
-
def deny(self, by=None, description=None, **kwargs):
|
|
817
|
-
self.orders.all().delete()
|
|
818
|
-
with suppress(KeyError):
|
|
819
|
-
del self.__dict__["validated_trading_service"]
|
|
820
|
-
|
|
821
|
-
def can_deny(self):
|
|
822
|
-
errors = dict()
|
|
823
|
-
if not self.orders.all().exists():
|
|
824
|
-
errors["non_field_errors"] = [
|
|
825
|
-
_("At least one order needs to be submitted to be able to deny this proposal")
|
|
826
|
-
]
|
|
827
|
-
return errors
|
|
828
|
-
|
|
829
|
-
@transition(
|
|
830
|
-
field=status,
|
|
831
|
-
source=Status.SUBMIT,
|
|
1046
|
+
source=Status.PENDING,
|
|
832
1047
|
target=Status.DRAFT,
|
|
833
1048
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
834
1049
|
user.profile, portfolio=instance.portfolio
|
|
@@ -857,7 +1072,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
857
1072
|
|
|
858
1073
|
@transition(
|
|
859
1074
|
field=status,
|
|
860
|
-
source=Status.
|
|
1075
|
+
source=Status.APPLIED,
|
|
861
1076
|
target=Status.DRAFT,
|
|
862
1077
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
863
1078
|
user.profile, portfolio=instance.portfolio
|
|
@@ -875,6 +1090,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
875
1090
|
},
|
|
876
1091
|
)
|
|
877
1092
|
def revert(self, **kwargs):
|
|
1093
|
+
self.approver = None
|
|
878
1094
|
with suppress(KeyError):
|
|
879
1095
|
del self.__dict__["validated_trading_service"]
|
|
880
1096
|
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
@@ -889,6 +1105,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
889
1105
|
]
|
|
890
1106
|
return errors
|
|
891
1107
|
|
|
1108
|
+
@transition(
|
|
1109
|
+
field=status,
|
|
1110
|
+
source=Status.APPROVED,
|
|
1111
|
+
target=Status.EXECUTION,
|
|
1112
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1113
|
+
user.profile, portfolio=instance.portfolio
|
|
1114
|
+
)
|
|
1115
|
+
and (user != instance.approver or user.is_superuser)
|
|
1116
|
+
and instance.custodian_adapter
|
|
1117
|
+
and not instance.has_non_successful_checks,
|
|
1118
|
+
custom={
|
|
1119
|
+
"_transition_button": ActionButton(
|
|
1120
|
+
method=RequestType.PATCH,
|
|
1121
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
1122
|
+
icon=WBIcon.DEAL_MONEY.icon,
|
|
1123
|
+
key="execute",
|
|
1124
|
+
label="Execute",
|
|
1125
|
+
action_label="Execute",
|
|
1126
|
+
description_fields="<p>Execute the orders through the setup custodian.</p>",
|
|
1127
|
+
)
|
|
1128
|
+
},
|
|
1129
|
+
)
|
|
1130
|
+
def execute(self, **kwargs):
|
|
1131
|
+
self.execution_status = ExecutionStatus.PENDING
|
|
1132
|
+
self.execution_comment = "Waiting for custodian confirmation"
|
|
1133
|
+
execute_orders_as_task.delay(self.id)
|
|
1134
|
+
|
|
1135
|
+
def can_execute(self):
|
|
1136
|
+
if not self.custodian_adapter:
|
|
1137
|
+
return {"portfolio": ["No custodian adapter"]}
|
|
1138
|
+
|
|
1139
|
+
@transition(
|
|
1140
|
+
field=status,
|
|
1141
|
+
source=Status.EXECUTION,
|
|
1142
|
+
target=Status.APPROVED,
|
|
1143
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1144
|
+
user.profile, portfolio=instance.portfolio
|
|
1145
|
+
)
|
|
1146
|
+
and instance.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT],
|
|
1147
|
+
custom={
|
|
1148
|
+
"_transition_button": ActionButton(
|
|
1149
|
+
method=RequestType.PATCH,
|
|
1150
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
1151
|
+
icon=WBIcon.PREVIOUS.icon,
|
|
1152
|
+
key="cancelexecution",
|
|
1153
|
+
label="Cancel Execution",
|
|
1154
|
+
action_label="Cancel Execution",
|
|
1155
|
+
description_fields="<p>Cancel the current requested execution. Time sensitive operation.</p>",
|
|
1156
|
+
)
|
|
1157
|
+
},
|
|
1158
|
+
)
|
|
1159
|
+
def cancelexecution(self, **kwargs):
|
|
1160
|
+
warning = ""
|
|
1161
|
+
try:
|
|
1162
|
+
if cancel_rebalancing(self):
|
|
1163
|
+
self.execution_comment, self.execution_status_detail, self.execution_status = (
|
|
1164
|
+
"",
|
|
1165
|
+
"",
|
|
1166
|
+
ExecutionStatus.CANCELLED,
|
|
1167
|
+
)
|
|
1168
|
+
else:
|
|
1169
|
+
warning = "We could not cancel the rebalancing. It is probably already executed. Please refresh status or check with an administrator."
|
|
1170
|
+
except (RoutingException, ValueError) as e:
|
|
1171
|
+
warning = f"Could not cancel orders proposal {self}: {str(e)}"
|
|
1172
|
+
logger.error(warning)
|
|
1173
|
+
return warning
|
|
1174
|
+
|
|
1175
|
+
def can_cancelexecution(self):
|
|
1176
|
+
if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
|
|
1177
|
+
return {"execution_status": "Execution can only be cancelled if it is not already executed"}
|
|
1178
|
+
|
|
1179
|
+
def update_execution_status(self):
|
|
1180
|
+
try:
|
|
1181
|
+
self.execution_status, self.execution_status_detail = get_execution_status(self)
|
|
1182
|
+
self.save()
|
|
1183
|
+
except (RoutingException, ValueError) as e:
|
|
1184
|
+
logger.warning(f"Could not update rebalancing status: {str(e)}")
|
|
1185
|
+
|
|
892
1186
|
# End FSM logics
|
|
893
1187
|
|
|
894
1188
|
@classmethod
|
|
@@ -914,7 +1208,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
|
|
|
914
1208
|
if not raw and instance.status == OrderProposal.Status.FAILED:
|
|
915
1209
|
# we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
916
1210
|
instance.invalidate_future_order_proposal()
|
|
917
|
-
instance.invalidate_future_order_proposal()
|
|
918
1211
|
|
|
919
1212
|
|
|
920
1213
|
@shared_task(queue="portfolio")
|
|
@@ -931,3 +1224,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
|
931
1224
|
reverse_name="wbportfolio:portfolio-detail",
|
|
932
1225
|
reverse_args=[order_proposal.portfolio.id],
|
|
933
1226
|
)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@shared_task(queue="portfolio")
|
|
1230
|
+
def execute_orders_as_task(order_proposal_id: int):
|
|
1231
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1232
|
+
try:
|
|
1233
|
+
status, comment = execute_orders(order_proposal)
|
|
1234
|
+
except (ValueError, RoutingException) as e:
|
|
1235
|
+
logger.error(f"Could not execute orders proposal {order_proposal}: {str(e)}")
|
|
1236
|
+
status = ExecutionStatus.FAILED
|
|
1237
|
+
comment = str(e)
|
|
1238
|
+
order_proposal.execution_status = status
|
|
1239
|
+
order_proposal.execution_comment = comment
|
|
1240
|
+
order_proposal.save()
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
@shared_task(queue="portfolio")
|
|
1244
|
+
def push_model_change_as_task(
|
|
1245
|
+
model_order_proposal_id: int,
|
|
1246
|
+
user_id: int,
|
|
1247
|
+
only_for_portfolio_ids: list[int] | None = None,
|
|
1248
|
+
approve_automatically: bool = False,
|
|
1249
|
+
):
|
|
1250
|
+
# not happy with that but we will keep it for the MVP lifecycle
|
|
1251
|
+
model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
|
|
1252
|
+
trade_date = model_order_proposal.trade_date
|
|
1253
|
+
user = User.objects.get(id=user_id)
|
|
1254
|
+
from wbportfolio.models.rebalancing import RebalancingModel
|
|
1255
|
+
|
|
1256
|
+
model_rebalancing = RebalancingModel.objects.get(
|
|
1257
|
+
class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
|
|
1258
|
+
)
|
|
1259
|
+
product_html_list = "<ul>\n"
|
|
1260
|
+
for rel in model_order_proposal.portfolio.get_model_portfolio_relationships(trade_date):
|
|
1261
|
+
if not only_for_portfolio_ids or rel.portfolio.id in only_for_portfolio_ids:
|
|
1262
|
+
order_proposal, _ = OrderProposal.objects.update_or_create(
|
|
1263
|
+
portfolio=rel.portfolio, trade_date=trade_date, defaults={"rebalancing_model": model_rebalancing}
|
|
1264
|
+
)
|
|
1265
|
+
order_proposal.reset_orders()
|
|
1266
|
+
product_html_list += f"<li>{rel.portfolio}</li>\n"
|
|
1267
|
+
if approve_automatically:
|
|
1268
|
+
order_proposal.submit()
|
|
1269
|
+
order_proposal.approve(by=user)
|
|
1270
|
+
order_proposal.save()
|
|
1271
|
+
product_html_list += "</ul>"
|
|
1272
|
+
|
|
1273
|
+
send_notification(
|
|
1274
|
+
code="wbportfolio.order_proposal.push_model_changes",
|
|
1275
|
+
title="Portfolio Model changes are pushed to dependant portfolios",
|
|
1276
|
+
body=f"""
|
|
1277
|
+
<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>
|
|
1278
|
+
<p>To proceed with executing these orders, please review the following related portfolios: </p>
|
|
1279
|
+
{product_html_list}
|
|
1280
|
+
""",
|
|
1281
|
+
user=user,
|
|
1282
|
+
)
|