wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.1__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/handlers/trade.py +2 -2
- 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/customer_trade.py +7 -5
- 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 +512 -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 +7 -6
- 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 +42 -4
- wbportfolio/viewsets/orders/orders.py +33 -31
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/RECORD +64 -54
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.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,72 @@ 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 (
|
|
323
|
+
self.get_orders().exclude(underlying_instrument__is_cash=True).select_related("underlying_instrument")
|
|
324
|
+
):
|
|
325
|
+
instrument = order.underlying_instrument
|
|
326
|
+
# we support only the instrument type provided by the Order DTO class
|
|
327
|
+
asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
|
|
328
|
+
try:
|
|
329
|
+
if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
|
|
330
|
+
executable_orders.append(
|
|
331
|
+
OrderDTO(
|
|
332
|
+
id=order.id,
|
|
333
|
+
asset_class=OrderDTO.AssetType[asset_class],
|
|
334
|
+
weighting=float(order.weighting),
|
|
335
|
+
target_weight=float(order.target_weight),
|
|
336
|
+
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
|
+
refinitiv_identifier_code=instrument.refinitiv_identifier_code,
|
|
340
|
+
bloomberg_ticker=instrument.ticker,
|
|
341
|
+
sedol=instrument.sedol,
|
|
342
|
+
execution_instruction=self.execution_instruction,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
order.execution_confirmed = False
|
|
347
|
+
order.execution_comment = "Underlying instrument does not have a valid identifier."
|
|
348
|
+
order.save()
|
|
349
|
+
except (AttributeError, KeyError):
|
|
350
|
+
order.execution_confirmed = False
|
|
351
|
+
order.execution_comment = f"Unsupported asset class {asset_class.title()}."
|
|
352
|
+
order.save()
|
|
353
|
+
|
|
354
|
+
return executable_orders
|
|
355
|
+
|
|
247
356
|
def __str__(self) -> str:
|
|
248
357
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
249
358
|
|
|
@@ -251,56 +360,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
251
360
|
self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
|
|
252
361
|
) -> PortfolioDTO:
|
|
253
362
|
"""
|
|
254
|
-
|
|
363
|
+
Converts the internal portfolio state and pending orders into a PortfolioDTO.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
use_effective: Use effective share quantities for positions if True.
|
|
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
|
+
|
|
255
370
|
Returns:
|
|
256
|
-
|
|
371
|
+
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
257
372
|
"""
|
|
258
373
|
portfolio = {}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
374
|
+
|
|
375
|
+
# 1. Gather all non-cash, positively weighted assets from the existing portfolio.
|
|
376
|
+
for asset in self.portfolio.assets.filter(
|
|
377
|
+
date=self.last_effective_date,
|
|
378
|
+
# underlying_quote__is_cash=False,
|
|
379
|
+
# underlying_quote__is_cash_equivalent=False,
|
|
380
|
+
weighting__gt=0,
|
|
381
|
+
):
|
|
382
|
+
portfolio[asset.underlying_quote] = {
|
|
383
|
+
"shares": asset._shares,
|
|
384
|
+
"weighting": asset.weighting,
|
|
385
|
+
"delta_weight": Decimal("0"),
|
|
386
|
+
"price": asset._price,
|
|
387
|
+
"currency_fx_rate": asset._currency_fx_rate,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# 2. Add or update non-cash orders, possibly overriding weights.
|
|
391
|
+
for order in self.get_orders().filter(
|
|
392
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
393
|
+
):
|
|
269
394
|
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
270
|
-
delta_weight =
|
|
395
|
+
delta_weight = Decimal("0")
|
|
396
|
+
weighting = order.desired_target_weight
|
|
271
397
|
else:
|
|
272
398
|
delta_weight = order.weighting
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
399
|
+
weighting = order._previous_weight
|
|
400
|
+
|
|
401
|
+
portfolio[order.underlying_instrument] = {
|
|
402
|
+
"weighting": weighting,
|
|
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
|
+
}
|
|
408
|
+
|
|
409
|
+
# 3. Prepare a mapping from instrument IDs to weights for analytic calculations.
|
|
410
|
+
previous_weights = {instrument.id: float(info["weighting"]) for instrument, info in portfolio.items()}
|
|
411
|
+
|
|
412
|
+
# 4. Attempt to fetch analytic returns and portfolio contribution. Default on error.
|
|
281
413
|
try:
|
|
282
|
-
last_returns,
|
|
414
|
+
last_returns, contribution = self.portfolio.get_analytic_portfolio(
|
|
283
415
|
self.value_date, weights=previous_weights, use_dl=True
|
|
284
416
|
).get_contributions()
|
|
285
417
|
last_returns = last_returns.to_dict()
|
|
286
418
|
except ValueError:
|
|
287
|
-
last_returns,
|
|
419
|
+
last_returns, contribution = {}, 1
|
|
420
|
+
|
|
288
421
|
positions = []
|
|
289
422
|
total_weighting = Decimal("0")
|
|
423
|
+
|
|
424
|
+
# 5. Build PositionDTO objects for all instruments.
|
|
290
425
|
for instrument, row in portfolio.items():
|
|
291
426
|
weighting = row["weighting"]
|
|
292
427
|
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
293
428
|
|
|
294
|
-
if
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
429
|
+
# Optionally apply drift to weightings if required
|
|
430
|
+
if not use_effective and not use_desired_target_weight:
|
|
431
|
+
if contribution:
|
|
432
|
+
drifted_weight = round(
|
|
433
|
+
weighting * (daily_return + Decimal("1")) / Decimal(contribution),
|
|
298
434
|
Order.ORDER_WEIGHTING_PRECISION,
|
|
299
435
|
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
436
|
+
else:
|
|
437
|
+
drifted_weight = weighting
|
|
303
438
|
weighting = drifted_weight + row["delta_weight"]
|
|
439
|
+
|
|
440
|
+
# Assemble the position object
|
|
304
441
|
positions.append(
|
|
305
442
|
PositionDTO(
|
|
306
443
|
underlying_instrument=instrument.id,
|
|
@@ -316,9 +453,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
316
453
|
)
|
|
317
454
|
)
|
|
318
455
|
total_weighting += weighting
|
|
319
|
-
|
|
456
|
+
|
|
457
|
+
# 6. Optionally include a cash position to balance the total weighting.
|
|
458
|
+
if (
|
|
459
|
+
portfolio
|
|
460
|
+
and with_cash
|
|
461
|
+
and total_weighting
|
|
462
|
+
and self.total_effective_portfolio_weight
|
|
463
|
+
and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
|
|
464
|
+
):
|
|
320
465
|
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
321
466
|
positions.append(cash_position._build_dto())
|
|
467
|
+
|
|
322
468
|
return PortfolioDTO(positions)
|
|
323
469
|
|
|
324
470
|
# Start tools methods
|
|
@@ -374,10 +520,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
374
520
|
|
|
375
521
|
def fix_quantization(self):
|
|
376
522
|
if self.orders.exists():
|
|
377
|
-
|
|
523
|
+
orders = self.get_orders()
|
|
524
|
+
t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
378
525
|
# 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
526
|
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
380
|
-
biggest_order =
|
|
527
|
+
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
|
|
381
528
|
biggest_order.weighting -= quantize_error
|
|
382
529
|
biggest_order.save()
|
|
383
530
|
|
|
@@ -407,6 +554,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
407
554
|
"""
|
|
408
555
|
if self.rebalancing_model:
|
|
409
556
|
self.orders.all().delete()
|
|
557
|
+
else:
|
|
558
|
+
self.orders.filter(underlying_instrument__is_cash=True).delete()
|
|
410
559
|
# delete all existing orders
|
|
411
560
|
# Get effective and target portfolio
|
|
412
561
|
if not target_portfolio:
|
|
@@ -426,6 +575,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
426
575
|
else:
|
|
427
576
|
orders = service.trades_batch.trades_map.values()
|
|
428
577
|
|
|
578
|
+
objs = []
|
|
429
579
|
for order_dto in orders:
|
|
430
580
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
431
581
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -448,30 +598,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
448
598
|
daily_return=daily_return,
|
|
449
599
|
currency_fx_rate=currency_fx_rate,
|
|
450
600
|
)
|
|
451
|
-
order.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
order.
|
|
601
|
+
order.desired_target_weight = order_dto.target_weight
|
|
602
|
+
order.order_type = Order.get_type(
|
|
603
|
+
weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
|
|
604
|
+
)
|
|
605
|
+
order.pre_save()
|
|
606
|
+
# # if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
607
|
+
# if not order.price and order.weighting > 0:
|
|
608
|
+
# order.price = Decimal("0.0")
|
|
609
|
+
# order.weighting = -order_dto.effective_weight
|
|
610
|
+
objs.append(order)
|
|
611
|
+
Order.objects.bulk_create(
|
|
612
|
+
objs,
|
|
613
|
+
update_fields=[
|
|
614
|
+
"value_date",
|
|
615
|
+
"weighting",
|
|
616
|
+
"daily_return",
|
|
617
|
+
"currency_fx_rate",
|
|
618
|
+
"order_type",
|
|
619
|
+
"portfolio",
|
|
620
|
+
"price",
|
|
621
|
+
"price_gross",
|
|
622
|
+
"desired_target_weight",
|
|
623
|
+
# "shares"
|
|
624
|
+
],
|
|
625
|
+
unique_fields=["order_proposal", "underlying_instrument"],
|
|
626
|
+
update_conflicts=True,
|
|
627
|
+
batch_size=1000,
|
|
628
|
+
)
|
|
458
629
|
# 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
|
-
|
|
630
|
+
self.get_orders().exclude(underlying_instrument__is_cash=True).filter(
|
|
631
|
+
target_weight=0, effective_weight=0
|
|
632
|
+
).delete()
|
|
633
|
+
self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
|
|
634
|
+
# self.fix_quantization()
|
|
635
|
+
self.total_effective_portfolio_contribution = effective_portfolio.portfolio_contribution
|
|
636
|
+
self.save()
|
|
466
637
|
|
|
467
|
-
def
|
|
638
|
+
def apply_workflow(
|
|
468
639
|
self,
|
|
469
|
-
|
|
640
|
+
apply_automatically: bool = True,
|
|
470
641
|
silent_exception: bool = False,
|
|
471
642
|
force_reset_order: bool = False,
|
|
472
643
|
**reset_order_kwargs,
|
|
473
644
|
):
|
|
474
|
-
|
|
645
|
+
# before, we need to save all positions in the builder first because effective weight depends on it
|
|
646
|
+
self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
|
|
647
|
+
if self.status == OrderProposal.Status.APPLIED:
|
|
475
648
|
logger.info("Reverting order proposal ...")
|
|
476
649
|
self.revert()
|
|
477
650
|
if self.status == OrderProposal.Status.DRAFT:
|
|
@@ -488,26 +661,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
488
661
|
return
|
|
489
662
|
logger.info("Submitting order proposal ...")
|
|
490
663
|
self.submit()
|
|
491
|
-
if self.status == OrderProposal.Status.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
664
|
+
if self.status == OrderProposal.Status.PENDING:
|
|
665
|
+
self.approve()
|
|
666
|
+
if apply_automatically and self.portfolio.can_be_rebalanced:
|
|
667
|
+
logger.info("Applying order proposal ...")
|
|
668
|
+
self.apply(replay=False)
|
|
495
669
|
|
|
496
|
-
def replay(
|
|
670
|
+
def replay(
|
|
671
|
+
self,
|
|
672
|
+
broadcast_changes_at_date: bool = True,
|
|
673
|
+
reapply_order_proposal: bool = False,
|
|
674
|
+
synchronous: bool = False,
|
|
675
|
+
**reset_order_kwargs,
|
|
676
|
+
):
|
|
497
677
|
last_order_proposal = self
|
|
498
678
|
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.
|
|
679
|
+
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.APPLIED:
|
|
501
681
|
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
502
682
|
if not last_order_proposal_created:
|
|
503
|
-
if reapply_order_proposal:
|
|
683
|
+
if reapply_order_proposal or last_order_proposal.rebalancing_model:
|
|
504
684
|
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
505
|
-
last_order_proposal.
|
|
685
|
+
last_order_proposal.apply_workflow(
|
|
686
|
+
silent_exception=True, force_reset_order=True, **reset_order_kwargs
|
|
687
|
+
)
|
|
506
688
|
last_order_proposal.save()
|
|
507
689
|
else:
|
|
508
690
|
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
509
|
-
last_order_proposal.reset_orders()
|
|
510
|
-
if last_order_proposal.status != OrderProposal.Status.
|
|
691
|
+
last_order_proposal.reset_orders(**reset_order_kwargs)
|
|
692
|
+
if last_order_proposal.status != OrderProposal.Status.APPLIED:
|
|
511
693
|
break
|
|
512
694
|
next_order_proposal = last_order_proposal.next_order_proposal
|
|
513
695
|
if next_order_proposal:
|
|
@@ -546,8 +728,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
546
728
|
else:
|
|
547
729
|
last_order_proposal_created = False
|
|
548
730
|
last_order_proposal = next_order_proposal
|
|
549
|
-
|
|
550
|
-
|
|
731
|
+
self.portfolio.builder.schedule_change_at_dates(
|
|
732
|
+
synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
|
|
733
|
+
)
|
|
551
734
|
|
|
552
735
|
def invalidate_future_order_proposal(self):
|
|
553
736
|
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
@@ -555,7 +738,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
555
738
|
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
556
739
|
).delete()
|
|
557
740
|
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
558
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
741
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
559
742
|
):
|
|
560
743
|
future_order_proposal.revert()
|
|
561
744
|
future_order_proposal.save()
|
|
@@ -577,6 +760,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
577
760
|
"""
|
|
578
761
|
# 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
762
|
|
|
763
|
+
# if an order exists for this estimation and the target weight is 0, then we return the inverse of the effective shares
|
|
764
|
+
with suppress(Order.DoesNotExist):
|
|
765
|
+
order = self.get_orders().get(underlying_instrument=underlying_quote)
|
|
766
|
+
if order.target_weight == 0:
|
|
767
|
+
return -order.effective_shares
|
|
580
768
|
# Calculate the order's total value in the portfolio's currency
|
|
581
769
|
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
582
770
|
|
|
@@ -614,9 +802,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
614
802
|
# Retrieve orders with base information
|
|
615
803
|
orders = self.get_orders()
|
|
616
804
|
# Calculate the total target weight of all orders
|
|
617
|
-
total_target_weight = orders.
|
|
618
|
-
|
|
619
|
-
)["s"] or Decimal(0)
|
|
805
|
+
total_target_weight = orders.filter(
|
|
806
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
807
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
620
808
|
if target_cash_weight is None:
|
|
621
809
|
target_cash_weight = Decimal("1") - total_target_weight
|
|
622
810
|
|
|
@@ -657,7 +845,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
657
845
|
@transition(
|
|
658
846
|
field=status,
|
|
659
847
|
source=Status.DRAFT,
|
|
660
|
-
target=Status.
|
|
848
|
+
target=Status.PENDING,
|
|
661
849
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
662
850
|
user.profile, portfolio=instance.portfolio
|
|
663
851
|
),
|
|
@@ -720,18 +908,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
720
908
|
del self.__dict__["validated_trading_service"]
|
|
721
909
|
return errors
|
|
722
910
|
|
|
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
911
|
@transition(
|
|
728
912
|
field=status,
|
|
729
|
-
source=Status.
|
|
913
|
+
source=Status.PENDING,
|
|
730
914
|
target=Status.APPROVED,
|
|
731
915
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
732
916
|
user.profile, portfolio=instance.portfolio
|
|
733
917
|
)
|
|
734
|
-
and instance.
|
|
918
|
+
and not instance.has_non_successful_checks,
|
|
735
919
|
custom={
|
|
736
920
|
"_transition_button": ActionButton(
|
|
737
921
|
method=RequestType.PATCH,
|
|
@@ -744,7 +928,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
744
928
|
)
|
|
745
929
|
},
|
|
746
930
|
)
|
|
747
|
-
def approve(self, by=None, description=None,
|
|
931
|
+
def approve(self, by=None, description=None, **kwargs):
|
|
932
|
+
if by:
|
|
933
|
+
self.approver = getattr(by, "profile", None)
|
|
934
|
+
elif not self.approver:
|
|
935
|
+
self.approver = self.creator
|
|
936
|
+
|
|
937
|
+
def can_approve(self):
|
|
938
|
+
pass
|
|
939
|
+
|
|
940
|
+
@transition(
|
|
941
|
+
field=status,
|
|
942
|
+
source=Status.PENDING,
|
|
943
|
+
target=Status.DENIED,
|
|
944
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
945
|
+
user.profile, portfolio=instance.portfolio
|
|
946
|
+
)
|
|
947
|
+
and not instance.has_non_successful_checks,
|
|
948
|
+
custom={
|
|
949
|
+
"_transition_button": ActionButton(
|
|
950
|
+
method=RequestType.PATCH,
|
|
951
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
952
|
+
icon=WBIcon.DENY.icon,
|
|
953
|
+
key="deny",
|
|
954
|
+
label="Deny",
|
|
955
|
+
action_label="Deny",
|
|
956
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
957
|
+
)
|
|
958
|
+
},
|
|
959
|
+
)
|
|
960
|
+
def deny(self, by=None, description=None, **kwargs):
|
|
961
|
+
pass
|
|
962
|
+
|
|
963
|
+
def can_deny(self):
|
|
964
|
+
pass
|
|
965
|
+
|
|
966
|
+
@property
|
|
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):
|
|
748
990
|
# We validate order which will create or update the initial asset positions
|
|
749
991
|
if not self.portfolio.can_be_rebalanced:
|
|
750
992
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -755,39 +997,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
755
997
|
for order in self.get_orders():
|
|
756
998
|
with suppress(ValueError):
|
|
757
999
|
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
758
|
-
if
|
|
1000
|
+
if (
|
|
1001
|
+
order.underlying_instrument != estimated_cash_position.underlying_quote
|
|
1002
|
+
and order._target_weight > 0
|
|
1003
|
+
):
|
|
759
1004
|
assets[order.underlying_instrument.id] = order._target_weight
|
|
760
1005
|
|
|
761
1006
|
# 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:
|
|
1007
|
+
if estimated_cash_position.weighting > 0 and len(assets) > 0:
|
|
763
1008
|
warnings.append(
|
|
764
1009
|
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
765
1010
|
)
|
|
766
1011
|
assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
|
|
767
|
-
|
|
768
1012
|
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
769
|
-
force_save=True, is_estimated=False
|
|
1013
|
+
force_save=True, is_estimated=False, delete_leftovers=True
|
|
770
1014
|
)
|
|
771
1015
|
if replay and self.portfolio.is_manageable:
|
|
772
|
-
replay_as_task.delay(
|
|
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
|
|
773
1021
|
return warnings
|
|
774
1022
|
|
|
775
|
-
def
|
|
1023
|
+
def can_apply(self):
|
|
776
1024
|
errors = dict()
|
|
777
1025
|
orders = self.get_orders()
|
|
778
1026
|
if not self.portfolio.can_be_rebalanced:
|
|
779
1027
|
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
780
1028
|
if not orders.exists():
|
|
781
1029
|
errors["non_field_errors"] = [
|
|
782
|
-
_("At least one order needs to be submitted to be able to
|
|
1030
|
+
_("At least one order needs to be submitted to be able to apply this proposal")
|
|
783
1031
|
]
|
|
784
1032
|
if not self.portfolio.can_be_rebalanced:
|
|
785
1033
|
errors["portfolio"] = [
|
|
786
|
-
[_("The portfolio needs to be a model portfolio in order to
|
|
1034
|
+
[_("The portfolio needs to be a model portfolio in order to apply this order proposal manually")]
|
|
787
1035
|
]
|
|
788
1036
|
if self.has_non_successful_checks:
|
|
789
1037
|
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
790
|
-
if orders.filter(has_warnings=True).
|
|
1038
|
+
if orders.filter(has_warnings=True).filter(
|
|
1039
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
1040
|
+
):
|
|
791
1041
|
errors["non_field_errors"] = [
|
|
792
1042
|
_("There is warning that needs to be addresses on the orders before approval.")
|
|
793
1043
|
]
|
|
@@ -795,40 +1045,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
795
1045
|
|
|
796
1046
|
@transition(
|
|
797
1047
|
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,
|
|
1048
|
+
source=Status.PENDING,
|
|
832
1049
|
target=Status.DRAFT,
|
|
833
1050
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
834
1051
|
user.profile, portfolio=instance.portfolio
|
|
@@ -857,7 +1074,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
857
1074
|
|
|
858
1075
|
@transition(
|
|
859
1076
|
field=status,
|
|
860
|
-
source=Status.
|
|
1077
|
+
source=Status.APPLIED,
|
|
861
1078
|
target=Status.DRAFT,
|
|
862
1079
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
863
1080
|
user.profile, portfolio=instance.portfolio
|
|
@@ -875,6 +1092,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
875
1092
|
},
|
|
876
1093
|
)
|
|
877
1094
|
def revert(self, **kwargs):
|
|
1095
|
+
self.approver = None
|
|
878
1096
|
with suppress(KeyError):
|
|
879
1097
|
del self.__dict__["validated_trading_service"]
|
|
880
1098
|
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
@@ -889,6 +1107,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
889
1107
|
]
|
|
890
1108
|
return errors
|
|
891
1109
|
|
|
1110
|
+
@transition(
|
|
1111
|
+
field=status,
|
|
1112
|
+
source=Status.APPROVED,
|
|
1113
|
+
target=Status.EXECUTION,
|
|
1114
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1115
|
+
user.profile, portfolio=instance.portfolio
|
|
1116
|
+
)
|
|
1117
|
+
and (user != instance.approver or user.is_superuser)
|
|
1118
|
+
and instance.custodian_adapter
|
|
1119
|
+
and not instance.has_non_successful_checks,
|
|
1120
|
+
custom={
|
|
1121
|
+
"_transition_button": ActionButton(
|
|
1122
|
+
method=RequestType.PATCH,
|
|
1123
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
1124
|
+
icon=WBIcon.DEAL_MONEY.icon,
|
|
1125
|
+
key="execute",
|
|
1126
|
+
label="Execute",
|
|
1127
|
+
action_label="Execute",
|
|
1128
|
+
description_fields="<p>Execute the orders through the setup custodian.</p>",
|
|
1129
|
+
)
|
|
1130
|
+
},
|
|
1131
|
+
)
|
|
1132
|
+
def execute(self, **kwargs):
|
|
1133
|
+
self.execution_status = ExecutionStatus.PENDING
|
|
1134
|
+
self.execution_comment = "Waiting for custodian confirmation"
|
|
1135
|
+
execute_orders_as_task.delay(self.id)
|
|
1136
|
+
|
|
1137
|
+
def can_execute(self):
|
|
1138
|
+
if not self.custodian_adapter:
|
|
1139
|
+
return {"portfolio": ["No custodian adapter"]}
|
|
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
|
+
)
|
|
1170
|
+
else:
|
|
1171
|
+
warning = "We could not cancel the rebalancing. It is probably already executed. Please refresh status or check with an administrator."
|
|
1172
|
+
except (RoutingException, ValueError) as e:
|
|
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)}")
|
|
1187
|
+
|
|
892
1188
|
# End FSM logics
|
|
893
1189
|
|
|
894
1190
|
@classmethod
|
|
@@ -914,7 +1210,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
|
|
|
914
1210
|
if not raw and instance.status == OrderProposal.Status.FAILED:
|
|
915
1211
|
# we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
916
1212
|
instance.invalidate_future_order_proposal()
|
|
917
|
-
instance.invalidate_future_order_proposal()
|
|
918
1213
|
|
|
919
1214
|
|
|
920
1215
|
@shared_task(queue="portfolio")
|
|
@@ -931,3 +1226,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
|
931
1226
|
reverse_name="wbportfolio:portfolio-detail",
|
|
932
1227
|
reverse_args=[order_proposal.portfolio.id],
|
|
933
1228
|
)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
@shared_task(queue="portfolio")
|
|
1232
|
+
def execute_orders_as_task(order_proposal_id: int):
|
|
1233
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1234
|
+
try:
|
|
1235
|
+
status, comment = execute_orders(order_proposal)
|
|
1236
|
+
except (ValueError, RoutingException) as e:
|
|
1237
|
+
logger.error(f"Could not execute orders proposal {order_proposal}: {str(e)}")
|
|
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")
|
|
1246
|
+
def push_model_change_as_task(
|
|
1247
|
+
model_order_proposal_id: int,
|
|
1248
|
+
user_id: int,
|
|
1249
|
+
only_for_portfolio_ids: list[int] | None = None,
|
|
1250
|
+
approve_automatically: bool = False,
|
|
1251
|
+
):
|
|
1252
|
+
# not happy with that but we will keep it for the MVP lifecycle
|
|
1253
|
+
model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
|
|
1254
|
+
trade_date = model_order_proposal.trade_date
|
|
1255
|
+
user = User.objects.get(id=user_id)
|
|
1256
|
+
from wbportfolio.models.rebalancing import RebalancingModel
|
|
1257
|
+
|
|
1258
|
+
model_rebalancing = RebalancingModel.objects.get(
|
|
1259
|
+
class_path="wbportfolio.rebalancing.models.model_portfolio.ModelPortfolioRebalancing"
|
|
1260
|
+
)
|
|
1261
|
+
product_html_list = "<ul>\n"
|
|
1262
|
+
for rel in model_order_proposal.portfolio.get_model_portfolio_relationships(trade_date):
|
|
1263
|
+
if not only_for_portfolio_ids or rel.portfolio.id in only_for_portfolio_ids:
|
|
1264
|
+
order_proposal, _ = OrderProposal.objects.update_or_create(
|
|
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()
|
|
1273
|
+
product_html_list += "</ul>"
|
|
1274
|
+
|
|
1275
|
+
send_notification(
|
|
1276
|
+
code="wbportfolio.order_proposal.push_model_changes",
|
|
1277
|
+
title="Portfolio Model changes are pushed to dependant portfolios",
|
|
1278
|
+
body=f"""
|
|
1279
|
+
<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>
|
|
1280
|
+
<p>To proceed with executing these orders, please review the following related portfolios: </p>
|
|
1281
|
+
{product_html_list}
|
|
1282
|
+
""",
|
|
1283
|
+
user=user,
|
|
1284
|
+
)
|