wbportfolio 1.54.22__py2.py3-none-any.whl → 1.55.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +10 -2
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +74 -31
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +549 -167
- wbportfolio/models/orders/orders.py +24 -11
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +77 -41
- wbportfolio/models/products.py +9 -0
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -1
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +25 -21
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +341 -155
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +47 -7
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,9 +85,34 @@ 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
|
)
|
|
99
|
+
total_cash_weight = models.DecimalField(
|
|
100
|
+
default=Decimal("0"),
|
|
101
|
+
decimal_places=4,
|
|
102
|
+
max_digits=5,
|
|
103
|
+
verbose_name="Total Cash Weight",
|
|
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%.",
|
|
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")
|
|
84
116
|
|
|
85
117
|
class Meta:
|
|
86
118
|
verbose_name = "Order Proposal"
|
|
@@ -92,6 +124,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
92
124
|
),
|
|
93
125
|
]
|
|
94
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
|
+
|
|
95
138
|
def save(self, *args, **kwargs):
|
|
96
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
|
|
97
140
|
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
@@ -134,6 +177,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
134
177
|
except AssetPosition.DoesNotExist:
|
|
135
178
|
return self.value_date
|
|
136
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
|
+
|
|
137
187
|
@cached_property
|
|
138
188
|
def value_date(self) -> date:
|
|
139
189
|
return (self.trade_date - BDay(1)).date()
|
|
@@ -141,7 +191,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
141
191
|
@property
|
|
142
192
|
def previous_order_proposal(self) -> SelfOrderProposal | None:
|
|
143
193
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
144
|
-
trade_date__lt=self.trade_date, status=OrderProposal.Status.
|
|
194
|
+
trade_date__lt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
145
195
|
)
|
|
146
196
|
if future_proposals.exists():
|
|
147
197
|
return future_proposals.latest("trade_date")
|
|
@@ -150,7 +200,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
150
200
|
@property
|
|
151
201
|
def next_order_proposal(self) -> SelfOrderProposal | None:
|
|
152
202
|
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
153
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
203
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
154
204
|
)
|
|
155
205
|
if future_proposals.exists():
|
|
156
206
|
return future_proposals.earliest("trade_date")
|
|
@@ -158,12 +208,34 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
158
208
|
|
|
159
209
|
@property
|
|
160
210
|
def cash_component(self) -> Cash:
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
|
|
211
|
+
return self.portfolio.cash_component
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def total_effective_portfolio_weight(self) -> Decimal:
|
|
215
|
+
return Decimal("1.0")
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def total_expected_target_weight(self) -> Decimal:
|
|
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"
|
|
164
232
|
|
|
165
233
|
def get_orders(self):
|
|
166
|
-
|
|
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),
|
|
167
239
|
last_effective_date=Subquery(
|
|
168
240
|
AssetPosition.unannotated_objects.filter(
|
|
169
241
|
date__lt=OuterRef("value_date"),
|
|
@@ -172,27 +244,33 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
172
244
|
.order_by("-date")
|
|
173
245
|
.values("date")[:1]
|
|
174
246
|
),
|
|
175
|
-
previous_weight=
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
),
|
|
185
263
|
),
|
|
186
|
-
|
|
264
|
+
default=Value(self.total_effective_portfolio_cash_weight),
|
|
187
265
|
),
|
|
188
266
|
contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
|
|
189
|
-
)
|
|
190
|
-
portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
|
|
191
|
-
orders = base_qs.annotate(
|
|
192
267
|
effective_weight=Round(
|
|
193
|
-
|
|
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,
|
|
194
273
|
),
|
|
195
|
-
tmp_effective_weight=F("contribution") / Value(portfolio_contribution),
|
|
196
274
|
target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
|
|
197
275
|
effective_shares=Coalesce(
|
|
198
276
|
Subquery(
|
|
@@ -209,79 +287,155 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
209
287
|
),
|
|
210
288
|
target_shares=F("effective_shares") + F("shares"),
|
|
211
289
|
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
models.
|
|
219
|
-
|
|
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"),
|
|
220
302
|
),
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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"),
|
|
226
309
|
),
|
|
227
|
-
|
|
228
|
-
),
|
|
229
|
-
)
|
|
310
|
+
)
|
|
230
311
|
return orders.annotate(
|
|
231
312
|
has_warnings=models.Case(
|
|
232
|
-
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),
|
|
233
317
|
),
|
|
234
318
|
)
|
|
235
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
|
+
|
|
236
354
|
def __str__(self) -> str:
|
|
237
355
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
238
356
|
|
|
239
|
-
def convert_to_portfolio(
|
|
357
|
+
def convert_to_portfolio(
|
|
358
|
+
self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
|
|
359
|
+
) -> PortfolioDTO:
|
|
240
360
|
"""
|
|
241
|
-
|
|
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
|
+
|
|
242
368
|
Returns:
|
|
243
|
-
|
|
369
|
+
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
244
370
|
"""
|
|
245
371
|
portfolio = {}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
weighting
|
|
257
|
-
delta_weight
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
):
|
|
392
|
+
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
393
|
+
delta_weight = Decimal("0")
|
|
394
|
+
weighting = order.desired_target_weight
|
|
395
|
+
else:
|
|
396
|
+
delta_weight = order.weighting
|
|
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.
|
|
263
411
|
try:
|
|
264
|
-
last_returns,
|
|
412
|
+
last_returns, contribution = self.portfolio.get_analytic_portfolio(
|
|
265
413
|
self.value_date, weights=previous_weights, use_dl=True
|
|
266
414
|
).get_contributions()
|
|
267
415
|
last_returns = last_returns.to_dict()
|
|
268
416
|
except ValueError:
|
|
269
|
-
last_returns,
|
|
417
|
+
last_returns, contribution = {}, 1
|
|
418
|
+
|
|
270
419
|
positions = []
|
|
271
420
|
total_weighting = Decimal("0")
|
|
421
|
+
|
|
422
|
+
# 5. Build PositionDTO objects for all instruments.
|
|
272
423
|
for instrument, row in portfolio.items():
|
|
273
424
|
weighting = row["weighting"]
|
|
274
425
|
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
426
|
+
|
|
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),
|
|
279
432
|
Order.ORDER_WEIGHTING_PRECISION,
|
|
280
433
|
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
434
|
+
else:
|
|
435
|
+
drifted_weight = weighting
|
|
284
436
|
weighting = drifted_weight + row["delta_weight"]
|
|
437
|
+
|
|
438
|
+
# Assemble the position object
|
|
285
439
|
positions.append(
|
|
286
440
|
PositionDTO(
|
|
287
441
|
underlying_instrument=instrument.id,
|
|
@@ -297,9 +451,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
297
451
|
)
|
|
298
452
|
)
|
|
299
453
|
total_weighting += weighting
|
|
300
|
-
|
|
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
|
+
):
|
|
301
463
|
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
302
464
|
positions.append(cash_position._build_dto())
|
|
465
|
+
|
|
303
466
|
return PortfolioDTO(positions)
|
|
304
467
|
|
|
305
468
|
# Start tools methods
|
|
@@ -332,7 +495,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
332
495
|
|
|
333
496
|
return order_proposal_clone
|
|
334
497
|
|
|
335
|
-
def normalize_orders(self
|
|
498
|
+
def normalize_orders(self):
|
|
336
499
|
"""
|
|
337
500
|
Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
|
|
338
501
|
The existing order will be modified directly with the given normalization factor
|
|
@@ -341,7 +504,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
341
504
|
self.trade_date,
|
|
342
505
|
effective_portfolio=self._get_default_effective_portfolio(),
|
|
343
506
|
target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
|
|
344
|
-
total_target_weight=
|
|
507
|
+
total_target_weight=self.total_expected_target_weight,
|
|
345
508
|
)
|
|
346
509
|
leftovers_orders = self.orders.all()
|
|
347
510
|
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
@@ -351,14 +514,19 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
351
514
|
order.save()
|
|
352
515
|
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
353
516
|
leftovers_orders.delete()
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
517
|
+
self.fix_quantization()
|
|
518
|
+
|
|
519
|
+
def fix_quantization(self):
|
|
520
|
+
if self.orders.exists():
|
|
521
|
+
orders = self.get_orders()
|
|
522
|
+
t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
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
|
|
524
|
+
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
525
|
+
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
|
|
526
|
+
biggest_order.weighting -= quantize_error
|
|
527
|
+
biggest_order.save()
|
|
528
|
+
|
|
529
|
+
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
362
530
|
if self.rebalancing_model:
|
|
363
531
|
params = {}
|
|
364
532
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -367,7 +535,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
367
535
|
return self.rebalancing_model.get_target_portfolio(
|
|
368
536
|
self.portfolio, self.trade_date, self.value_date, **params
|
|
369
537
|
)
|
|
370
|
-
return self.convert_to_portfolio(use_effective=False)
|
|
538
|
+
return self.convert_to_portfolio(use_effective=False, use_desired_target_weight=use_desired_target_weight)
|
|
371
539
|
|
|
372
540
|
def _get_default_effective_portfolio(self):
|
|
373
541
|
return self.convert_to_portfolio(use_effective=True)
|
|
@@ -377,17 +545,19 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
377
545
|
target_portfolio: PortfolioDTO | None = None,
|
|
378
546
|
effective_portfolio: PortfolioRole | None = None,
|
|
379
547
|
validate_order: bool = True,
|
|
380
|
-
|
|
548
|
+
use_desired_target_weight: bool = False,
|
|
381
549
|
):
|
|
382
550
|
"""
|
|
383
551
|
Will delete all existing orders and recreate them from the method `create_or_update_trades`
|
|
384
552
|
"""
|
|
385
553
|
if self.rebalancing_model:
|
|
386
554
|
self.orders.all().delete()
|
|
555
|
+
else:
|
|
556
|
+
self.orders.filter(underlying_instrument__is_cash=True).delete()
|
|
387
557
|
# delete all existing orders
|
|
388
558
|
# Get effective and target portfolio
|
|
389
559
|
if not target_portfolio:
|
|
390
|
-
target_portfolio = self._get_default_target_portfolio()
|
|
560
|
+
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
391
561
|
if not effective_portfolio:
|
|
392
562
|
effective_portfolio = self._get_default_effective_portfolio()
|
|
393
563
|
if target_portfolio:
|
|
@@ -395,13 +565,15 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
395
565
|
self.trade_date,
|
|
396
566
|
effective_portfolio=effective_portfolio,
|
|
397
567
|
target_portfolio=target_portfolio,
|
|
398
|
-
total_target_weight=
|
|
568
|
+
total_target_weight=self.total_expected_target_weight,
|
|
399
569
|
)
|
|
400
570
|
if validate_order:
|
|
401
571
|
service.is_valid()
|
|
402
572
|
orders = service.validated_trades
|
|
403
573
|
else:
|
|
404
574
|
orders = service.trades_batch.trades_map.values()
|
|
575
|
+
|
|
576
|
+
objs = []
|
|
405
577
|
for order_dto in orders:
|
|
406
578
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
407
579
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -424,25 +596,53 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
424
596
|
daily_return=daily_return,
|
|
425
597
|
currency_fx_rate=currency_fx_rate,
|
|
426
598
|
)
|
|
427
|
-
order.
|
|
428
|
-
order.order_type = Order.get_type(
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
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
|
+
)
|
|
435
627
|
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
436
|
-
self.get_orders().
|
|
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()
|
|
437
635
|
|
|
438
|
-
def
|
|
636
|
+
def apply_workflow(
|
|
439
637
|
self,
|
|
440
|
-
|
|
638
|
+
apply_automatically: bool = True,
|
|
441
639
|
silent_exception: bool = False,
|
|
442
640
|
force_reset_order: bool = False,
|
|
443
641
|
**reset_order_kwargs,
|
|
444
642
|
):
|
|
445
|
-
|
|
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:
|
|
446
646
|
logger.info("Reverting order proposal ...")
|
|
447
647
|
self.revert()
|
|
448
648
|
if self.status == OrderProposal.Status.DRAFT:
|
|
@@ -459,26 +659,35 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
459
659
|
return
|
|
460
660
|
logger.info("Submitting order proposal ...")
|
|
461
661
|
self.submit()
|
|
462
|
-
if self.status == OrderProposal.Status.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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)
|
|
466
667
|
|
|
467
|
-
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
|
+
):
|
|
468
675
|
last_order_proposal = self
|
|
469
676
|
last_order_proposal_created = False
|
|
470
|
-
self.portfolio.load_builder_returns(self.trade_date, date.today())
|
|
471
|
-
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:
|
|
472
679
|
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
473
680
|
if not last_order_proposal_created:
|
|
474
|
-
if reapply_order_proposal:
|
|
681
|
+
if reapply_order_proposal or last_order_proposal.rebalancing_model:
|
|
475
682
|
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
476
|
-
last_order_proposal.
|
|
683
|
+
last_order_proposal.apply_workflow(
|
|
684
|
+
silent_exception=True, force_reset_order=True, **reset_order_kwargs
|
|
685
|
+
)
|
|
477
686
|
last_order_proposal.save()
|
|
478
687
|
else:
|
|
479
688
|
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
480
|
-
last_order_proposal.reset_orders()
|
|
481
|
-
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:
|
|
482
691
|
break
|
|
483
692
|
next_order_proposal = last_order_proposal.next_order_proposal
|
|
484
693
|
if next_order_proposal:
|
|
@@ -517,8 +726,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
517
726
|
else:
|
|
518
727
|
last_order_proposal_created = False
|
|
519
728
|
last_order_proposal = next_order_proposal
|
|
520
|
-
|
|
521
|
-
|
|
729
|
+
self.portfolio.builder.schedule_change_at_dates(
|
|
730
|
+
synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
|
|
731
|
+
)
|
|
522
732
|
|
|
523
733
|
def invalidate_future_order_proposal(self):
|
|
524
734
|
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
@@ -526,7 +736,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
526
736
|
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
527
737
|
).delete()
|
|
528
738
|
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
529
|
-
trade_date__gt=self.trade_date, status=OrderProposal.Status.
|
|
739
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPLIED
|
|
530
740
|
):
|
|
531
741
|
future_order_proposal.revert()
|
|
532
742
|
future_order_proposal.save()
|
|
@@ -548,6 +758,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
548
758
|
"""
|
|
549
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
|
|
550
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
|
|
551
766
|
# Calculate the order's total value in the portfolio's currency
|
|
552
767
|
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
553
768
|
|
|
@@ -585,9 +800,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
585
800
|
# Retrieve orders with base information
|
|
586
801
|
orders = self.get_orders()
|
|
587
802
|
# Calculate the total target weight of all orders
|
|
588
|
-
total_target_weight = orders.
|
|
589
|
-
|
|
590
|
-
)["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)
|
|
591
806
|
if target_cash_weight is None:
|
|
592
807
|
target_cash_weight = Decimal("1") - total_target_weight
|
|
593
808
|
|
|
@@ -628,7 +843,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
628
843
|
@transition(
|
|
629
844
|
field=status,
|
|
630
845
|
source=Status.DRAFT,
|
|
631
|
-
target=Status.
|
|
846
|
+
target=Status.PENDING,
|
|
632
847
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
633
848
|
user.profile, portfolio=instance.portfolio
|
|
634
849
|
),
|
|
@@ -647,13 +862,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
647
862
|
def submit(self, by=None, description=None, **kwargs):
|
|
648
863
|
orders = []
|
|
649
864
|
orders_validation_warnings = []
|
|
650
|
-
|
|
865
|
+
qs = self.get_orders()
|
|
866
|
+
total_target_weight = Decimal("0")
|
|
867
|
+
for order in qs:
|
|
651
868
|
order_warnings = order.submit(
|
|
652
869
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
653
870
|
)
|
|
654
871
|
if order_warnings:
|
|
655
872
|
orders_validation_warnings.extend(order_warnings)
|
|
656
873
|
orders.append(order)
|
|
874
|
+
total_target_weight += order._target_weight
|
|
657
875
|
|
|
658
876
|
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
659
877
|
|
|
@@ -663,6 +881,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
663
881
|
estimated_cash_position._build_dto()
|
|
664
882
|
)
|
|
665
883
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
884
|
+
self.total_cash_weight = Decimal("1") - total_target_weight
|
|
666
885
|
return orders_validation_warnings
|
|
667
886
|
|
|
668
887
|
def can_submit(self):
|
|
@@ -687,18 +906,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
687
906
|
del self.__dict__["validated_trading_service"]
|
|
688
907
|
return errors
|
|
689
908
|
|
|
690
|
-
@property
|
|
691
|
-
def can_be_approved_or_denied(self):
|
|
692
|
-
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
693
|
-
|
|
694
909
|
@transition(
|
|
695
910
|
field=status,
|
|
696
|
-
source=Status.
|
|
911
|
+
source=Status.PENDING,
|
|
697
912
|
target=Status.APPROVED,
|
|
698
913
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
699
914
|
user.profile, portfolio=instance.portfolio
|
|
700
915
|
)
|
|
701
|
-
and instance.
|
|
916
|
+
and not instance.has_non_successful_checks,
|
|
702
917
|
custom={
|
|
703
918
|
"_transition_button": ActionButton(
|
|
704
919
|
method=RequestType.PATCH,
|
|
@@ -711,7 +926,65 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
711
926
|
)
|
|
712
927
|
},
|
|
713
928
|
)
|
|
714
|
-
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):
|
|
715
988
|
# We validate order which will create or update the initial asset positions
|
|
716
989
|
if not self.portfolio.can_be_rebalanced:
|
|
717
990
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -722,39 +995,47 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
722
995
|
for order in self.get_orders():
|
|
723
996
|
with suppress(ValueError):
|
|
724
997
|
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
725
|
-
if
|
|
998
|
+
if (
|
|
999
|
+
order.underlying_instrument != estimated_cash_position.underlying_quote
|
|
1000
|
+
and order._target_weight > 0
|
|
1001
|
+
):
|
|
726
1002
|
assets[order.underlying_instrument.id] = order._target_weight
|
|
727
1003
|
|
|
728
1004
|
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
729
|
-
if estimated_cash_position.weighting and len(assets) > 0:
|
|
1005
|
+
if estimated_cash_position.weighting > 0 and len(assets) > 0:
|
|
730
1006
|
warnings.append(
|
|
731
1007
|
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
732
1008
|
)
|
|
733
1009
|
assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
|
|
734
|
-
|
|
735
1010
|
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
736
|
-
force_save=True, is_estimated=False
|
|
1011
|
+
force_save=True, is_estimated=False, delete_leftovers=True
|
|
737
1012
|
)
|
|
738
1013
|
if replay and self.portfolio.is_manageable:
|
|
739
|
-
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
|
|
740
1019
|
return warnings
|
|
741
1020
|
|
|
742
|
-
def
|
|
1021
|
+
def can_apply(self):
|
|
743
1022
|
errors = dict()
|
|
744
1023
|
orders = self.get_orders()
|
|
745
1024
|
if not self.portfolio.can_be_rebalanced:
|
|
746
1025
|
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
747
1026
|
if not orders.exists():
|
|
748
1027
|
errors["non_field_errors"] = [
|
|
749
|
-
_("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")
|
|
750
1029
|
]
|
|
751
1030
|
if not self.portfolio.can_be_rebalanced:
|
|
752
1031
|
errors["portfolio"] = [
|
|
753
|
-
[_("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")]
|
|
754
1033
|
]
|
|
755
1034
|
if self.has_non_successful_checks:
|
|
756
1035
|
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
757
|
-
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
|
+
):
|
|
758
1039
|
errors["non_field_errors"] = [
|
|
759
1040
|
_("There is warning that needs to be addresses on the orders before approval.")
|
|
760
1041
|
]
|
|
@@ -762,40 +1043,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
762
1043
|
|
|
763
1044
|
@transition(
|
|
764
1045
|
field=status,
|
|
765
|
-
source=Status.
|
|
766
|
-
target=Status.DENIED,
|
|
767
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
768
|
-
user.profile, portfolio=instance.portfolio
|
|
769
|
-
)
|
|
770
|
-
and instance.can_be_approved_or_denied,
|
|
771
|
-
custom={
|
|
772
|
-
"_transition_button": ActionButton(
|
|
773
|
-
method=RequestType.PATCH,
|
|
774
|
-
identifiers=("wbportfolio:orderproposal",),
|
|
775
|
-
icon=WBIcon.DENY.icon,
|
|
776
|
-
key="deny",
|
|
777
|
-
label="Deny",
|
|
778
|
-
action_label="Deny",
|
|
779
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
780
|
-
)
|
|
781
|
-
},
|
|
782
|
-
)
|
|
783
|
-
def deny(self, by=None, description=None, **kwargs):
|
|
784
|
-
self.orders.all().delete()
|
|
785
|
-
with suppress(KeyError):
|
|
786
|
-
del self.__dict__["validated_trading_service"]
|
|
787
|
-
|
|
788
|
-
def can_deny(self):
|
|
789
|
-
errors = dict()
|
|
790
|
-
if not self.orders.all().exists():
|
|
791
|
-
errors["non_field_errors"] = [
|
|
792
|
-
_("At least one order needs to be submitted to be able to deny this proposal")
|
|
793
|
-
]
|
|
794
|
-
return errors
|
|
795
|
-
|
|
796
|
-
@transition(
|
|
797
|
-
field=status,
|
|
798
|
-
source=Status.SUBMIT,
|
|
1046
|
+
source=Status.PENDING,
|
|
799
1047
|
target=Status.DRAFT,
|
|
800
1048
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
801
1049
|
user.profile, portfolio=instance.portfolio
|
|
@@ -824,7 +1072,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
824
1072
|
|
|
825
1073
|
@transition(
|
|
826
1074
|
field=status,
|
|
827
|
-
source=Status.
|
|
1075
|
+
source=Status.APPLIED,
|
|
828
1076
|
target=Status.DRAFT,
|
|
829
1077
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
830
1078
|
user.profile, portfolio=instance.portfolio
|
|
@@ -842,6 +1090,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
842
1090
|
},
|
|
843
1091
|
)
|
|
844
1092
|
def revert(self, **kwargs):
|
|
1093
|
+
self.approver = None
|
|
845
1094
|
with suppress(KeyError):
|
|
846
1095
|
del self.__dict__["validated_trading_service"]
|
|
847
1096
|
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
@@ -856,6 +1105,84 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
856
1105
|
]
|
|
857
1106
|
return errors
|
|
858
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
|
+
|
|
859
1186
|
# End FSM logics
|
|
860
1187
|
|
|
861
1188
|
@classmethod
|
|
@@ -881,7 +1208,6 @@ def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kw
|
|
|
881
1208
|
if not raw and instance.status == OrderProposal.Status.FAILED:
|
|
882
1209
|
# we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
883
1210
|
instance.invalidate_future_order_proposal()
|
|
884
|
-
instance.invalidate_future_order_proposal()
|
|
885
1211
|
|
|
886
1212
|
|
|
887
1213
|
@shared_task(queue="portfolio")
|
|
@@ -898,3 +1224,59 @@ def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
|
898
1224
|
reverse_name="wbportfolio:portfolio-detail",
|
|
899
1225
|
reverse_args=[order_proposal.portfolio.id],
|
|
900
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
|
+
)
|