wbportfolio 1.54.13__py2.py3-none-any.whl → 1.54.15__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/__init__.py +2 -0
- wbportfolio/admin/orders/__init__.py +2 -0
- wbportfolio/admin/orders/order_proposals.py +14 -0
- wbportfolio/admin/orders/orders.py +30 -0
- wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -1
- wbportfolio/admin/transactions/trades.py +2 -17
- wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
- wbportfolio/factories/__init__.py +2 -1
- wbportfolio/factories/orders/__init__.py +2 -0
- wbportfolio/factories/orders/order_proposals.py +17 -0
- wbportfolio/factories/orders/orders.py +21 -0
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/factories/trades.py +2 -13
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/orders.py +11 -0
- wbportfolio/import_export/handlers/trade.py +20 -20
- wbportfolio/import_export/resources/trades.py +2 -2
- wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
- wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
- wbportfolio/models/__init__.py +2 -0
- wbportfolio/models/orders/__init__.py +2 -0
- wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +289 -245
- wbportfolio/models/orders/orders.py +243 -0
- wbportfolio/models/portfolio.py +17 -20
- wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
- wbportfolio/models/transactions/__init__.py +0 -2
- wbportfolio/models/transactions/trades.py +10 -450
- wbportfolio/pms/analytics/portfolio.py +10 -6
- wbportfolio/pms/analytics/utils.py +9 -0
- wbportfolio/pms/trading/handler.py +6 -4
- wbportfolio/pms/typing.py +18 -7
- wbportfolio/rebalancing/decorators.py +1 -1
- wbportfolio/rebalancing/models/composite.py +3 -7
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
- wbportfolio/serializers/__init__.py +1 -0
- wbportfolio/serializers/orders/__init__.py +2 -0
- wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +23 -15
- wbportfolio/serializers/orders/orders.py +187 -0
- wbportfolio/serializers/portfolios.py +7 -7
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/serializers/transactions/__init__.py +1 -5
- wbportfolio/serializers/transactions/trades.py +1 -182
- wbportfolio/tests/conftest.py +4 -2
- wbportfolio/tests/models/orders/__init__.py +0 -0
- wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +218 -246
- wbportfolio/tests/models/test_portfolios.py +11 -10
- wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
- wbportfolio/tests/models/transactions/test_trades.py +0 -20
- wbportfolio/tests/rebalancing/test_models.py +24 -28
- wbportfolio/tests/signals.py +10 -10
- wbportfolio/tests/tests.py +1 -1
- wbportfolio/urls.py +7 -7
- wbportfolio/viewsets/__init__.py +2 -0
- wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
- wbportfolio/viewsets/configs/buttons/trades.py +0 -8
- wbportfolio/viewsets/configs/display/__init__.py +0 -2
- wbportfolio/viewsets/configs/display/portfolios.py +5 -5
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/display/trades.py +1 -225
- wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
- wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
- wbportfolio/viewsets/orders/__init__.py +6 -0
- wbportfolio/viewsets/orders/configs/__init__.py +4 -0
- wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
- wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +22 -21
- wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
- wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
- wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
- wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
- wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
- wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
- wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
- wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -45
- wbportfolio/viewsets/orders/orders.py +219 -0
- wbportfolio/viewsets/portfolios.py +12 -12
- wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
- wbportfolio/viewsets/transactions/__init__.py +1 -7
- wbportfolio/viewsets/transactions/trades.py +1 -199
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/RECORD +85 -58
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,6 +8,14 @@ from typing import Any, TypeVar
|
|
|
8
8
|
from celery import shared_task
|
|
9
9
|
from django.core.exceptions import ValidationError
|
|
10
10
|
from django.db import DatabaseError, models
|
|
11
|
+
from django.db.models import (
|
|
12
|
+
F,
|
|
13
|
+
OuterRef,
|
|
14
|
+
Subquery,
|
|
15
|
+
Sum,
|
|
16
|
+
Value,
|
|
17
|
+
)
|
|
18
|
+
from django.db.models.functions import Coalesce, Round
|
|
11
19
|
from django.db.models.signals import post_save
|
|
12
20
|
from django.dispatch import receiver
|
|
13
21
|
from django.utils.functional import cached_property
|
|
@@ -26,60 +34,60 @@ from wbcore.utils.models import CloneMixin
|
|
|
26
34
|
from wbfdm.models import InstrumentPrice
|
|
27
35
|
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
28
36
|
|
|
37
|
+
from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
|
|
38
|
+
from wbportfolio.models.exceptions import InvalidAnalyticPortfolio
|
|
29
39
|
from wbportfolio.models.roles import PortfolioRole
|
|
30
40
|
from wbportfolio.pms.trading import TradingService
|
|
31
41
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
32
42
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
33
43
|
|
|
34
|
-
from
|
|
35
|
-
from ..exceptions import InvalidAnalyticPortfolio
|
|
36
|
-
from .trades import Trade
|
|
44
|
+
from .orders import Order
|
|
37
45
|
|
|
38
46
|
logger = logging.getLogger("pms")
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
SelfOrderProposal = TypeVar("SelfOrderProposal", bound="OrderProposal")
|
|
41
49
|
|
|
42
50
|
|
|
43
|
-
class
|
|
51
|
+
class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
44
52
|
trade_date = models.DateField(verbose_name="Trading Date")
|
|
45
53
|
|
|
46
54
|
class Status(models.TextChoices):
|
|
47
55
|
DRAFT = "DRAFT", "Draft"
|
|
48
|
-
SUBMIT = "SUBMIT", "
|
|
56
|
+
SUBMIT = "SUBMIT", "Pending"
|
|
49
57
|
APPROVED = "APPROVED", "Approved"
|
|
50
58
|
DENIED = "DENIED", "Denied"
|
|
51
59
|
FAILED = "FAILED", "Failed"
|
|
52
60
|
|
|
53
|
-
comment = models.TextField(default="", verbose_name="
|
|
61
|
+
comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
|
|
54
62
|
status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name="Status")
|
|
55
63
|
rebalancing_model = models.ForeignKey(
|
|
56
64
|
"wbportfolio.RebalancingModel",
|
|
57
65
|
on_delete=models.SET_NULL,
|
|
58
66
|
blank=True,
|
|
59
67
|
null=True,
|
|
60
|
-
related_name="
|
|
68
|
+
related_name="order_proposals",
|
|
61
69
|
verbose_name="Rebalancing Model",
|
|
62
70
|
help_text="Rebalancing Model that generates the target portfolio",
|
|
63
71
|
)
|
|
64
72
|
portfolio = models.ForeignKey(
|
|
65
|
-
"wbportfolio.Portfolio", related_name="
|
|
73
|
+
"wbportfolio.Portfolio", related_name="order_proposals", on_delete=models.PROTECT, verbose_name="Portfolio"
|
|
66
74
|
)
|
|
67
75
|
creator = models.ForeignKey(
|
|
68
76
|
"directory.Person",
|
|
69
77
|
blank=True,
|
|
70
78
|
null=True,
|
|
71
|
-
related_name="
|
|
79
|
+
related_name="order_proposals",
|
|
72
80
|
on_delete=models.PROTECT,
|
|
73
81
|
verbose_name="Owner",
|
|
74
82
|
)
|
|
75
83
|
|
|
76
84
|
class Meta:
|
|
77
|
-
verbose_name = "
|
|
78
|
-
verbose_name_plural = "
|
|
85
|
+
verbose_name = "Order Proposal"
|
|
86
|
+
verbose_name_plural = "Order Proposals"
|
|
79
87
|
constraints = [
|
|
80
88
|
models.UniqueConstraint(
|
|
81
89
|
fields=["portfolio", "trade_date"],
|
|
82
|
-
name="
|
|
90
|
+
name="unique_order_proposal",
|
|
83
91
|
),
|
|
84
92
|
]
|
|
85
93
|
|
|
@@ -87,8 +95,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
87
95
|
if not self.trade_date and self.portfolio.assets.exists():
|
|
88
96
|
self.trade_date = (self.portfolio.assets.latest("date").date + BDay(1)).date()
|
|
89
97
|
|
|
90
|
-
# if a
|
|
91
|
-
if not self.portfolio.
|
|
98
|
+
# 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
|
|
99
|
+
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
92
100
|
new_inception_date = (self.trade_date + BDay(1)).date()
|
|
93
101
|
self.portfolio.instruments.filter(inception_date__gt=new_inception_date).update(
|
|
94
102
|
inception_date=new_inception_date
|
|
@@ -133,38 +141,87 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
133
141
|
return (self.trade_date - BDay(1)).date()
|
|
134
142
|
|
|
135
143
|
@property
|
|
136
|
-
def
|
|
137
|
-
future_proposals =
|
|
138
|
-
trade_date__lt=self.trade_date, status=
|
|
144
|
+
def previous_order_proposal(self) -> SelfOrderProposal | None:
|
|
145
|
+
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
146
|
+
trade_date__lt=self.trade_date, status=OrderProposal.Status.APPROVED
|
|
139
147
|
)
|
|
140
148
|
if future_proposals.exists():
|
|
141
149
|
return future_proposals.latest("trade_date")
|
|
142
150
|
return None
|
|
143
151
|
|
|
144
152
|
@property
|
|
145
|
-
def
|
|
146
|
-
future_proposals =
|
|
147
|
-
trade_date__gt=self.trade_date, status=
|
|
153
|
+
def next_order_proposal(self) -> SelfOrderProposal | None:
|
|
154
|
+
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
155
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
|
|
148
156
|
)
|
|
149
157
|
if future_proposals.exists():
|
|
150
158
|
return future_proposals.earliest("trade_date")
|
|
151
159
|
return None
|
|
152
160
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
def get_orders(self):
|
|
162
|
+
base_qs = self.orders.all().annotate(
|
|
163
|
+
last_effective_date=Subquery(
|
|
164
|
+
AssetPosition.unannotated_objects.filter(
|
|
165
|
+
date__lt=OuterRef("value_date"),
|
|
166
|
+
portfolio=OuterRef("portfolio"),
|
|
167
|
+
)
|
|
168
|
+
.order_by("-date")
|
|
169
|
+
.values("date")[:1]
|
|
170
|
+
),
|
|
171
|
+
previous_weight=Coalesce(
|
|
172
|
+
Subquery(
|
|
173
|
+
AssetPosition.unannotated_objects.filter(
|
|
174
|
+
underlying_quote=OuterRef("underlying_instrument"),
|
|
175
|
+
date=OuterRef("last_effective_date"),
|
|
176
|
+
portfolio=OuterRef("portfolio"),
|
|
177
|
+
)
|
|
178
|
+
.values("portfolio")
|
|
179
|
+
.annotate(s=Sum("weighting"))
|
|
180
|
+
.values("s")[:1]
|
|
181
|
+
),
|
|
182
|
+
Decimal(0),
|
|
183
|
+
),
|
|
184
|
+
contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
|
|
185
|
+
)
|
|
186
|
+
portfolio_contribution = base_qs.aggregate(s=Sum("contribution"))["s"] or Decimal("1")
|
|
187
|
+
orders = base_qs.annotate(
|
|
188
|
+
effective_weight=Round(
|
|
189
|
+
F("contribution") / Value(portfolio_contribution), precision=Order.ORDER_WEIGHTING_PRECISION
|
|
190
|
+
),
|
|
191
|
+
target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
|
|
192
|
+
effective_shares=Coalesce(
|
|
193
|
+
Subquery(
|
|
194
|
+
AssetPosition.objects.filter(
|
|
195
|
+
underlying_quote=OuterRef("underlying_instrument"),
|
|
196
|
+
date=OuterRef("last_effective_date"),
|
|
197
|
+
portfolio=OuterRef("portfolio"),
|
|
198
|
+
)
|
|
199
|
+
.values("portfolio")
|
|
200
|
+
.annotate(s=Sum("shares"))
|
|
201
|
+
.values("s")[:1]
|
|
202
|
+
),
|
|
203
|
+
Decimal(0),
|
|
204
|
+
),
|
|
205
|
+
target_shares=F("effective_shares") + F("shares"),
|
|
206
|
+
)
|
|
207
|
+
total_effective_weight = orders.aggregate(s=models.Sum("effective_weight"))["s"] or Decimal("1")
|
|
208
|
+
with suppress(Order.DoesNotExist):
|
|
209
|
+
largest_order = orders.latest("weighting")
|
|
210
|
+
if quant_error := Decimal("1") - total_effective_weight:
|
|
211
|
+
orders = orders.annotate(
|
|
212
|
+
effective_weight=models.Case(
|
|
213
|
+
models.When(
|
|
214
|
+
id=largest_order.id, then=models.F("effective_weight") + models.Value(Decimal(quant_error))
|
|
215
|
+
),
|
|
216
|
+
default=models.F("effective_weight"),
|
|
217
|
+
),
|
|
218
|
+
target_weight=models.F("effective_weight") + models.F("weighting"),
|
|
219
|
+
)
|
|
220
|
+
return orders.annotate(
|
|
221
|
+
has_warnings=models.Case(
|
|
222
|
+
models.When(models.Q(price=0) | models.Q(target_weight__lt=0), then=Value(True)), default=Value(False)
|
|
223
|
+
),
|
|
224
|
+
)
|
|
168
225
|
|
|
169
226
|
def __str__(self) -> str:
|
|
170
227
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
@@ -173,7 +230,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
173
230
|
"""
|
|
174
231
|
Data Transfer Object
|
|
175
232
|
Returns:
|
|
176
|
-
DTO
|
|
233
|
+
DTO order object
|
|
177
234
|
"""
|
|
178
235
|
portfolio = {}
|
|
179
236
|
for asset in self.portfolio.assets.filter(date=self.last_effective_date):
|
|
@@ -184,37 +241,42 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
184
241
|
price=asset._price,
|
|
185
242
|
currency_fx_rate=asset._currency_fx_rate,
|
|
186
243
|
)
|
|
187
|
-
for
|
|
188
|
-
portfolio[
|
|
189
|
-
weighting=
|
|
190
|
-
delta_weight=
|
|
191
|
-
shares=
|
|
192
|
-
price=
|
|
193
|
-
currency_fx_rate=
|
|
244
|
+
for order in self.get_orders():
|
|
245
|
+
portfolio[order.underlying_instrument] = dict(
|
|
246
|
+
weighting=order._previous_weight,
|
|
247
|
+
delta_weight=order.weighting,
|
|
248
|
+
shares=order._target_shares if not use_effective else order._effective_shares,
|
|
249
|
+
price=order.price,
|
|
250
|
+
currency_fx_rate=order.currency_fx_rate,
|
|
194
251
|
)
|
|
195
|
-
|
|
196
252
|
previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
|
|
197
253
|
try:
|
|
198
|
-
|
|
254
|
+
last_returns, portfolio_contribution = self.portfolio.get_analytic_portfolio(
|
|
199
255
|
self.value_date, weights=previous_weights, use_dl=True
|
|
200
|
-
).
|
|
256
|
+
).get_contributions()
|
|
257
|
+
last_returns = last_returns.to_dict()
|
|
201
258
|
except InvalidAnalyticPortfolio:
|
|
202
|
-
|
|
259
|
+
last_returns, portfolio_contribution = {}, 1
|
|
203
260
|
positions = []
|
|
204
261
|
for instrument, row in portfolio.items():
|
|
205
262
|
weighting = row["weighting"]
|
|
206
|
-
|
|
207
|
-
drift_factor = Decimal(drifted_weights.pop(instrument.id)) / weighting if weighting else Decimal("1")
|
|
208
|
-
except KeyError:
|
|
209
|
-
drift_factor = Decimal("1")
|
|
263
|
+
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
210
264
|
if not use_effective:
|
|
211
|
-
|
|
265
|
+
drifted_weight = (
|
|
266
|
+
round(
|
|
267
|
+
weighting * (daily_return + Decimal("1")) / Decimal(portfolio_contribution),
|
|
268
|
+
Order.ORDER_WEIGHTING_PRECISION,
|
|
269
|
+
)
|
|
270
|
+
if portfolio_contribution
|
|
271
|
+
else weighting
|
|
272
|
+
)
|
|
273
|
+
weighting = drifted_weight + row["delta_weight"]
|
|
212
274
|
positions.append(
|
|
213
275
|
PositionDTO(
|
|
214
276
|
underlying_instrument=instrument.id,
|
|
215
277
|
instrument_type=instrument.instrument_type.id,
|
|
216
278
|
weighting=weighting,
|
|
217
|
-
|
|
279
|
+
daily_return=daily_return if use_effective else Decimal("0"),
|
|
218
280
|
shares=row["shares"],
|
|
219
281
|
currency=instrument.currency.id,
|
|
220
282
|
date=self.last_effective_date if use_effective else self.trade_date,
|
|
@@ -226,39 +288,39 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
226
288
|
return PortfolioDTO(positions)
|
|
227
289
|
|
|
228
290
|
# Start tools methods
|
|
229
|
-
def _clone(self, **kwargs) ->
|
|
291
|
+
def _clone(self, **kwargs) -> SelfOrderProposal:
|
|
230
292
|
"""
|
|
231
|
-
Method to clone self as a new
|
|
293
|
+
Method to clone self as a new order proposal. It will automatically shift the order date if a proposal already exists
|
|
232
294
|
Args:
|
|
233
295
|
**kwargs: The keyword arguments
|
|
234
296
|
Returns:
|
|
235
|
-
The cloned
|
|
297
|
+
The cloned order proposal
|
|
236
298
|
"""
|
|
237
299
|
trade_date = kwargs.get("clone_date", self.trade_date)
|
|
238
300
|
|
|
239
|
-
# Find the next valid
|
|
240
|
-
while
|
|
301
|
+
# Find the next valid order date
|
|
302
|
+
while OrderProposal.objects.filter(portfolio=self.portfolio, trade_date=trade_date).exists():
|
|
241
303
|
trade_date += timedelta(days=1)
|
|
242
304
|
|
|
243
|
-
|
|
305
|
+
order_proposal_clone = OrderProposal.objects.create(
|
|
244
306
|
trade_date=trade_date,
|
|
245
307
|
comment=kwargs.get("clone_comment", self.comment),
|
|
246
|
-
status=
|
|
308
|
+
status=OrderProposal.Status.DRAFT,
|
|
247
309
|
rebalancing_model=self.rebalancing_model,
|
|
248
310
|
portfolio=self.portfolio,
|
|
249
311
|
creator=self.creator,
|
|
250
312
|
)
|
|
251
|
-
for
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
313
|
+
for order in self.orders.all():
|
|
314
|
+
order.id = None
|
|
315
|
+
order.order_proposal = order_proposal_clone
|
|
316
|
+
order.save()
|
|
255
317
|
|
|
256
|
-
return
|
|
318
|
+
return order_proposal_clone
|
|
257
319
|
|
|
258
|
-
def
|
|
320
|
+
def normalize_orders(self, total_target_weight: Decimal = Decimal("1.0")):
|
|
259
321
|
"""
|
|
260
|
-
Call the trading service with the existing
|
|
261
|
-
The existing
|
|
322
|
+
Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
|
|
323
|
+
The existing order will be modified directly with the given normalization factor
|
|
262
324
|
"""
|
|
263
325
|
service = TradingService(
|
|
264
326
|
self.trade_date,
|
|
@@ -266,22 +328,20 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
266
328
|
target_portfolio=self.convert_to_portfolio(),
|
|
267
329
|
total_target_weight=total_target_weight,
|
|
268
330
|
)
|
|
269
|
-
|
|
270
|
-
for underlying_instrument_id,
|
|
271
|
-
with suppress(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
t_weight = self.
|
|
278
|
-
|
|
279
|
-
] or Decimal("0.0")
|
|
280
|
-
# we handle quantization error due to the decimal max digits. In that case, we take the biggest trade (highest weight) and we remove the quantization error
|
|
331
|
+
leftovers_orders = self.orders.all()
|
|
332
|
+
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
333
|
+
with suppress(Order.DoesNotExist):
|
|
334
|
+
order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
|
|
335
|
+
order.weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
|
|
336
|
+
order.save()
|
|
337
|
+
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
338
|
+
leftovers_orders.delete()
|
|
339
|
+
t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
340
|
+
# 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
|
|
281
341
|
if quantize_error := (t_weight - total_target_weight):
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
342
|
+
biggest_order = self.orders.latest("weighting")
|
|
343
|
+
biggest_order.weighting -= quantize_error
|
|
344
|
+
biggest_order.save()
|
|
285
345
|
|
|
286
346
|
def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
|
|
287
347
|
if self.rebalancing_model:
|
|
@@ -292,33 +352,29 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
292
352
|
return self.rebalancing_model.get_target_portfolio(
|
|
293
353
|
self.portfolio, self.trade_date, self.value_date, **params
|
|
294
354
|
)
|
|
295
|
-
|
|
296
|
-
return self.convert_to_portfolio()
|
|
297
|
-
# Return the current portfolio by default
|
|
298
|
-
return self.convert_to_portfolio(use_effective=False)
|
|
355
|
+
return self.convert_to_portfolio()
|
|
299
356
|
|
|
300
357
|
def _get_default_effective_portfolio(self):
|
|
301
358
|
return self.convert_to_portfolio(use_effective=True)
|
|
302
359
|
|
|
303
|
-
def
|
|
360
|
+
def reset_orders(
|
|
304
361
|
self,
|
|
305
362
|
target_portfolio: PortfolioDTO | None = None,
|
|
306
363
|
effective_portfolio: PortfolioRole | None = None,
|
|
307
|
-
|
|
364
|
+
validate_order: bool = True,
|
|
308
365
|
total_target_weight: Decimal = Decimal("1.0"),
|
|
309
366
|
):
|
|
310
367
|
"""
|
|
311
|
-
Will delete all existing
|
|
368
|
+
Will delete all existing orders and recreate them from the method `create_or_update_trades`
|
|
312
369
|
"""
|
|
313
370
|
if self.rebalancing_model:
|
|
314
|
-
self.
|
|
315
|
-
# delete all existing
|
|
371
|
+
self.orders.all().delete()
|
|
372
|
+
# delete all existing orders
|
|
316
373
|
# Get effective and target portfolio
|
|
317
374
|
if not target_portfolio:
|
|
318
375
|
target_portfolio = self._get_default_target_portfolio()
|
|
319
376
|
if not effective_portfolio:
|
|
320
377
|
effective_portfolio = self._get_default_effective_portfolio()
|
|
321
|
-
|
|
322
378
|
if target_portfolio:
|
|
323
379
|
service = TradingService(
|
|
324
380
|
self.trade_date,
|
|
@@ -326,98 +382,94 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
326
382
|
target_portfolio=target_portfolio,
|
|
327
383
|
total_target_weight=total_target_weight,
|
|
328
384
|
)
|
|
329
|
-
if
|
|
385
|
+
if validate_order:
|
|
330
386
|
service.is_valid()
|
|
331
|
-
|
|
387
|
+
orders = service.validated_trades
|
|
332
388
|
else:
|
|
333
|
-
|
|
334
|
-
for
|
|
335
|
-
instrument = Instrument.objects.get(id=
|
|
336
|
-
if not instrument.is_cash: # we do not save
|
|
389
|
+
orders = service.trades_batch.trades_map.values()
|
|
390
|
+
for order_dto in orders:
|
|
391
|
+
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
392
|
+
if not instrument.is_cash: # we do not save order that includes cash component
|
|
337
393
|
currency_fx_rate = instrument.currency.convert(
|
|
338
394
|
self.value_date, self.portfolio.currency, exact_lookup=True
|
|
339
395
|
)
|
|
340
|
-
# we cannot do a bulk-create because
|
|
341
|
-
weighting = round(
|
|
342
|
-
|
|
396
|
+
# we cannot do a bulk-create because Order is a multi table inheritance
|
|
397
|
+
weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
|
|
398
|
+
daily_return = order_dto.daily_return
|
|
343
399
|
try:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
trade = Trade(
|
|
400
|
+
order = self.orders.get(underlying_instrument=instrument)
|
|
401
|
+
order.weighting = weighting
|
|
402
|
+
order.currency_fx_rate = currency_fx_rate
|
|
403
|
+
order.daily_return = daily_return
|
|
404
|
+
except Order.DoesNotExist:
|
|
405
|
+
order = Order(
|
|
351
406
|
underlying_instrument=instrument,
|
|
352
|
-
|
|
353
|
-
value_date=self.
|
|
354
|
-
transaction_date=self.trade_date,
|
|
355
|
-
trade_proposal=self,
|
|
356
|
-
portfolio=self.portfolio,
|
|
407
|
+
order_proposal=self,
|
|
408
|
+
value_date=self.trade_date,
|
|
357
409
|
weighting=weighting,
|
|
358
|
-
|
|
359
|
-
status=Trade.Status.DRAFT,
|
|
410
|
+
daily_return=daily_return,
|
|
360
411
|
currency_fx_rate=currency_fx_rate,
|
|
361
412
|
)
|
|
362
|
-
|
|
413
|
+
order.price = order.get_price()
|
|
414
|
+
order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
|
|
363
415
|
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
364
|
-
if not
|
|
365
|
-
|
|
366
|
-
|
|
416
|
+
if not order.price:
|
|
417
|
+
order.price = Decimal("0.0")
|
|
418
|
+
order.weighting = -order_dto.effective_weight
|
|
367
419
|
|
|
368
|
-
|
|
369
|
-
# final sanity check to make sure invalid
|
|
370
|
-
self.
|
|
420
|
+
order.save()
|
|
421
|
+
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
422
|
+
self.get_orders().filter(target_weight=0, effective_weight=0).delete()
|
|
371
423
|
|
|
372
424
|
def approve_workflow(
|
|
373
425
|
self,
|
|
374
426
|
approve_automatically: bool = True,
|
|
375
427
|
silent_exception: bool = False,
|
|
376
|
-
|
|
428
|
+
force_reset_order: bool = False,
|
|
377
429
|
broadcast_changes_at_date: bool = True,
|
|
378
|
-
**
|
|
430
|
+
**reset_order_kwargs,
|
|
379
431
|
):
|
|
380
|
-
if self.status ==
|
|
381
|
-
logger.info("Reverting
|
|
432
|
+
if self.status == OrderProposal.Status.APPROVED:
|
|
433
|
+
logger.info("Reverting order proposal ...")
|
|
382
434
|
self.revert()
|
|
383
|
-
if self.status ==
|
|
435
|
+
if self.status == OrderProposal.Status.DRAFT:
|
|
384
436
|
if (
|
|
385
|
-
self.rebalancing_model or
|
|
386
|
-
): # if there is no position (for any reason) or we the
|
|
387
|
-
logger.info("Resetting
|
|
388
|
-
try: # we silent any validation error while setting proposal, because if this happens, we assume the current
|
|
389
|
-
self.
|
|
437
|
+
self.rebalancing_model or force_reset_order
|
|
438
|
+
): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
|
|
439
|
+
logger.info("Resetting orders ...")
|
|
440
|
+
try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
|
|
441
|
+
self.reset_orders(**reset_order_kwargs)
|
|
390
442
|
except (ValidationError, DatabaseError) as e:
|
|
391
|
-
self.status =
|
|
443
|
+
self.status = OrderProposal.Status.FAILED
|
|
392
444
|
if not silent_exception:
|
|
393
445
|
raise ValidationError(e)
|
|
394
446
|
return
|
|
395
|
-
logger.info("Submitting
|
|
447
|
+
logger.info("Submitting order proposal ...")
|
|
396
448
|
self.submit()
|
|
397
|
-
if self.status ==
|
|
398
|
-
logger.info("Approving
|
|
449
|
+
if self.status == OrderProposal.Status.SUBMIT:
|
|
450
|
+
logger.info("Approving order proposal ...")
|
|
399
451
|
if approve_automatically and self.portfolio.can_be_rebalanced:
|
|
400
452
|
self.approve(replay=False, broadcast_changes_at_date=broadcast_changes_at_date)
|
|
401
453
|
|
|
402
454
|
def replay(self, broadcast_changes_at_date: bool = True):
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
while
|
|
406
|
-
if not
|
|
407
|
-
logger.info(f"Replaying
|
|
408
|
-
|
|
455
|
+
last_order_proposal = self
|
|
456
|
+
last_order_proposal_created = False
|
|
457
|
+
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPROVED:
|
|
458
|
+
if not last_order_proposal_created:
|
|
459
|
+
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
460
|
+
last_order_proposal.approve_workflow(
|
|
409
461
|
silent_exception=True,
|
|
410
|
-
|
|
462
|
+
force_reset_order=True,
|
|
411
463
|
broadcast_changes_at_date=broadcast_changes_at_date,
|
|
412
464
|
)
|
|
413
|
-
|
|
414
|
-
if
|
|
465
|
+
last_order_proposal.save()
|
|
466
|
+
if last_order_proposal.status != OrderProposal.Status.APPROVED:
|
|
415
467
|
break
|
|
416
|
-
|
|
417
|
-
if
|
|
418
|
-
next_trade_date =
|
|
468
|
+
next_order_proposal = last_order_proposal.next_order_proposal
|
|
469
|
+
if next_order_proposal:
|
|
470
|
+
next_trade_date = next_order_proposal.trade_date - timedelta(days=1)
|
|
419
471
|
elif next_expected_rebalancing_date := self.portfolio.get_next_rebalancing_date(
|
|
420
|
-
|
|
472
|
+
last_order_proposal.trade_date
|
|
421
473
|
):
|
|
422
474
|
next_trade_date = (
|
|
423
475
|
next_expected_rebalancing_date + timedelta(days=7)
|
|
@@ -425,15 +477,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
425
477
|
else:
|
|
426
478
|
next_trade_date = date.today()
|
|
427
479
|
next_trade_date = min(next_trade_date, date.today())
|
|
428
|
-
positions,
|
|
429
|
-
|
|
480
|
+
positions, overriding_order_proposal = self.portfolio.drift_weights(
|
|
481
|
+
last_order_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
|
|
430
482
|
)
|
|
431
483
|
|
|
432
484
|
# self.portfolio.assets.filter(
|
|
433
|
-
# date__gt=
|
|
485
|
+
# date__gt=last_order_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
|
|
434
486
|
# ).update(
|
|
435
487
|
# is_estimated=True
|
|
436
|
-
# ) # ensure that we reset non estimated position leftover to estimated between
|
|
488
|
+
# ) # ensure that we reset non estimated position leftover to estimated between order proposal during replay
|
|
437
489
|
self.portfolio.bulk_create_positions(
|
|
438
490
|
positions,
|
|
439
491
|
delete_leftovers=True,
|
|
@@ -441,49 +493,49 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
441
493
|
broadcast_changes_at_date=broadcast_changes_at_date,
|
|
442
494
|
evaluate_rebalancer=False,
|
|
443
495
|
)
|
|
444
|
-
for draft_tp in
|
|
496
|
+
for draft_tp in OrderProposal.objects.filter(
|
|
445
497
|
portfolio=self.portfolio,
|
|
446
|
-
trade_date__gt=
|
|
498
|
+
trade_date__gt=last_order_proposal.trade_date,
|
|
447
499
|
trade_date__lte=next_trade_date,
|
|
448
|
-
status=
|
|
500
|
+
status=OrderProposal.Status.DRAFT,
|
|
449
501
|
):
|
|
450
|
-
draft_tp.
|
|
451
|
-
if
|
|
452
|
-
|
|
453
|
-
|
|
502
|
+
draft_tp.reset_orders()
|
|
503
|
+
if overriding_order_proposal:
|
|
504
|
+
last_order_proposal_created = True
|
|
505
|
+
last_order_proposal = overriding_order_proposal
|
|
454
506
|
else:
|
|
455
|
-
|
|
456
|
-
|
|
507
|
+
last_order_proposal_created = False
|
|
508
|
+
last_order_proposal = next_order_proposal
|
|
457
509
|
|
|
458
|
-
def
|
|
459
|
-
# Delete all future automatic
|
|
460
|
-
self.portfolio.
|
|
510
|
+
def invalidate_future_order_proposal(self):
|
|
511
|
+
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
512
|
+
self.portfolio.order_proposals.filter(
|
|
461
513
|
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
462
514
|
).delete()
|
|
463
|
-
for
|
|
464
|
-
trade_date__gt=self.trade_date, status=
|
|
515
|
+
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
516
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.APPROVED
|
|
465
517
|
):
|
|
466
|
-
|
|
467
|
-
|
|
518
|
+
future_order_proposal.revert()
|
|
519
|
+
future_order_proposal.save()
|
|
468
520
|
|
|
469
521
|
def get_estimated_shares(
|
|
470
522
|
self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal
|
|
471
523
|
) -> Decimal | None:
|
|
472
524
|
"""
|
|
473
|
-
Estimates the number of shares for a
|
|
525
|
+
Estimates the number of shares for a order based on the given weight and underlying quote.
|
|
474
526
|
|
|
475
|
-
This method calculates the estimated shares by dividing the
|
|
527
|
+
This method calculates the estimated shares by dividing the order's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
|
|
476
528
|
|
|
477
529
|
Args:
|
|
478
|
-
weight (Decimal): The weight of the
|
|
479
|
-
underlying_quote (Instrument): The underlying instrument for the
|
|
530
|
+
weight (Decimal): The weight of the order.
|
|
531
|
+
underlying_quote (Instrument): The underlying instrument for the order.
|
|
480
532
|
|
|
481
533
|
Returns:
|
|
482
534
|
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
483
535
|
"""
|
|
484
|
-
# Retrieve the price of the underlying quote on the
|
|
536
|
+
# 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
|
|
485
537
|
|
|
486
|
-
# Calculate the
|
|
538
|
+
# Calculate the order's total value in the portfolio's currency
|
|
487
539
|
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
488
540
|
|
|
489
541
|
# Convert the quote price to the portfolio's currency
|
|
@@ -507,9 +559,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
507
559
|
|
|
508
560
|
def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
|
|
509
561
|
"""
|
|
510
|
-
Estimates the target cash weight and shares for a
|
|
562
|
+
Estimates the target cash weight and shares for a order proposal.
|
|
511
563
|
|
|
512
|
-
This method calculates the target cash weight by summing the weights of cash
|
|
564
|
+
This method calculates the target cash weight by summing the weights of cash orders and adding any leftover weight from non-cash orders. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
|
|
513
565
|
|
|
514
566
|
Args:
|
|
515
567
|
currency (Currency): The currency for the target currency component
|
|
@@ -517,17 +569,17 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
517
569
|
Returns:
|
|
518
570
|
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
519
571
|
"""
|
|
520
|
-
# Retrieve
|
|
521
|
-
|
|
572
|
+
# Retrieve orders with base information
|
|
573
|
+
orders = self.get_orders()
|
|
522
574
|
|
|
523
|
-
# Calculate the target cash weight from cash
|
|
524
|
-
target_cash_weight =
|
|
575
|
+
# Calculate the target cash weight from cash orders
|
|
576
|
+
target_cash_weight = orders.filter(
|
|
525
577
|
underlying_instrument__is_cash=True, underlying_instrument__currency=currency
|
|
526
578
|
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
527
579
|
# if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
|
|
528
580
|
if currency == self.portfolio.currency:
|
|
529
|
-
# Calculate the total target weight of all
|
|
530
|
-
total_target_weight =
|
|
581
|
+
# Calculate the total target weight of all orders
|
|
582
|
+
total_target_weight = orders.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
531
583
|
|
|
532
584
|
# Add any leftover weight as cash
|
|
533
585
|
target_cash_weight += Decimal(1) - total_target_weight
|
|
@@ -582,7 +634,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
582
634
|
custom={
|
|
583
635
|
"_transition_button": ActionButton(
|
|
584
636
|
method=RequestType.PATCH,
|
|
585
|
-
identifiers=("wbportfolio:
|
|
637
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
586
638
|
icon=WBIcon.SEND.icon,
|
|
587
639
|
key="submit",
|
|
588
640
|
label="Submit",
|
|
@@ -592,40 +644,38 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
592
644
|
},
|
|
593
645
|
)
|
|
594
646
|
def submit(self, by=None, description=None, **kwargs):
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
for
|
|
598
|
-
|
|
647
|
+
orders = []
|
|
648
|
+
orders_validation_warnings = []
|
|
649
|
+
for order in self.get_orders():
|
|
650
|
+
order_warnings = order.submit(
|
|
599
651
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
600
652
|
)
|
|
601
|
-
if
|
|
602
|
-
|
|
603
|
-
|
|
653
|
+
if order_warnings:
|
|
654
|
+
orders_validation_warnings.extend(order_warnings)
|
|
655
|
+
orders.append(order)
|
|
604
656
|
|
|
605
|
-
|
|
657
|
+
Order.objects.bulk_update(orders, ["shares", "weighting"])
|
|
606
658
|
|
|
607
|
-
# If we estimate cash on this
|
|
659
|
+
# If we estimate cash on this order proposal, we make sure to create the corresponding cash component
|
|
608
660
|
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
609
661
|
target_portfolio = self.validated_trading_service.trades_batch.convert_to_portfolio(
|
|
610
662
|
estimated_cash_position._build_dto()
|
|
611
663
|
)
|
|
612
664
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
613
|
-
return
|
|
665
|
+
return orders_validation_warnings
|
|
614
666
|
|
|
615
667
|
def can_submit(self):
|
|
616
668
|
errors = dict()
|
|
617
669
|
errors_list = []
|
|
618
|
-
if self.trades.exists() and self.trades.exclude(status=Trade.Status.DRAFT).exists():
|
|
619
|
-
errors_list.append(_("All trades need to be draft before submitting"))
|
|
620
670
|
service = self.validated_trading_service
|
|
621
671
|
try:
|
|
622
672
|
service.is_valid(ignore_error=True)
|
|
623
673
|
# if service.trades_batch.total_abs_delta_weight == 0:
|
|
624
674
|
# errors_list.append(
|
|
625
|
-
# "There is no change detected in this
|
|
675
|
+
# "There is no change detected in this order proposal. Please submit at last one valid order"
|
|
626
676
|
# )
|
|
627
677
|
if len(service.validated_trades) == 0:
|
|
628
|
-
errors_list.append(_("There is no valid
|
|
678
|
+
errors_list.append(_("There is no valid order on this proposal"))
|
|
629
679
|
if service.errors:
|
|
630
680
|
errors_list.extend(service.errors)
|
|
631
681
|
if errors_list:
|
|
@@ -651,7 +701,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
651
701
|
custom={
|
|
652
702
|
"_transition_button": ActionButton(
|
|
653
703
|
method=RequestType.PATCH,
|
|
654
|
-
identifiers=("wbportfolio:
|
|
704
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
655
705
|
icon=WBIcon.APPROVE.icon,
|
|
656
706
|
key="approve",
|
|
657
707
|
label="Approve",
|
|
@@ -661,33 +711,28 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
661
711
|
},
|
|
662
712
|
)
|
|
663
713
|
def approve(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
664
|
-
# We validate
|
|
714
|
+
# We validate order which will create or update the initial asset positions
|
|
665
715
|
if not self.portfolio.can_be_rebalanced:
|
|
666
716
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
667
|
-
trades = []
|
|
668
717
|
assets = []
|
|
669
718
|
warnings = []
|
|
670
|
-
# We do not want to create the estimated cash position if there is not
|
|
719
|
+
# We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
|
|
671
720
|
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
672
|
-
|
|
673
|
-
for trade in self.trades.all():
|
|
721
|
+
for order in self.get_orders():
|
|
674
722
|
with suppress(ValueError):
|
|
675
|
-
asset =
|
|
723
|
+
asset = order.get_asset()
|
|
676
724
|
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
677
725
|
if asset.underlying_quote != estimated_cash_position.underlying_quote:
|
|
678
726
|
assets.append(asset)
|
|
679
|
-
trade.status = Trade.Status.EXECUTED
|
|
680
|
-
trades.append(trade)
|
|
681
727
|
|
|
682
728
|
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
683
|
-
if estimated_cash_position.weighting and len(
|
|
729
|
+
if estimated_cash_position.weighting and len(assets) > 0:
|
|
684
730
|
warnings.append(
|
|
685
731
|
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
686
732
|
)
|
|
687
733
|
estimated_cash_position.pre_save()
|
|
688
734
|
assets.append(estimated_cash_position)
|
|
689
735
|
|
|
690
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
691
736
|
self.portfolio.bulk_create_positions(
|
|
692
737
|
AssetPositionIterator(self.portfolio).add(assets, is_estimated=False),
|
|
693
738
|
evaluate_rebalancer=False,
|
|
@@ -695,23 +740,28 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
695
740
|
**kwargs,
|
|
696
741
|
)
|
|
697
742
|
if replay and self.portfolio.is_manageable:
|
|
698
|
-
replay_as_task.delay(self.id, user_id=by.id if by else None)
|
|
743
|
+
replay_as_task.delay(self.id, user_id=by.id if by else None, broadcast_changes_at_date=False)
|
|
699
744
|
return warnings
|
|
700
745
|
|
|
701
746
|
def can_approve(self):
|
|
702
747
|
errors = dict()
|
|
748
|
+
orders = self.get_orders()
|
|
703
749
|
if not self.portfolio.can_be_rebalanced:
|
|
704
750
|
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
705
|
-
if
|
|
751
|
+
if not orders.exists():
|
|
706
752
|
errors["non_field_errors"] = [
|
|
707
|
-
_("At least one
|
|
753
|
+
_("At least one order needs to be submitted to be able to approve this proposal")
|
|
708
754
|
]
|
|
709
755
|
if not self.portfolio.can_be_rebalanced:
|
|
710
756
|
errors["portfolio"] = [
|
|
711
|
-
[_("The portfolio needs to be a model portfolio in order to approve this
|
|
757
|
+
[_("The portfolio needs to be a model portfolio in order to approve this order proposal manually")]
|
|
712
758
|
]
|
|
713
759
|
if self.has_non_successful_checks:
|
|
714
|
-
errors["non_field_errors"] = [_("The pre
|
|
760
|
+
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
761
|
+
if orders.filter(has_warnings=True):
|
|
762
|
+
errors["non_field_errors"] = [
|
|
763
|
+
_("There is warning that needs to be addresses on the orders before approval.")
|
|
764
|
+
]
|
|
715
765
|
return errors
|
|
716
766
|
|
|
717
767
|
@transition(
|
|
@@ -725,7 +775,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
725
775
|
custom={
|
|
726
776
|
"_transition_button": ActionButton(
|
|
727
777
|
method=RequestType.PATCH,
|
|
728
|
-
identifiers=("wbportfolio:
|
|
778
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
729
779
|
icon=WBIcon.DENY.icon,
|
|
730
780
|
key="deny",
|
|
731
781
|
label="Deny",
|
|
@@ -735,15 +785,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
735
785
|
},
|
|
736
786
|
)
|
|
737
787
|
def deny(self, by=None, description=None, **kwargs):
|
|
738
|
-
self.
|
|
788
|
+
self.orders.all().delete()
|
|
739
789
|
with suppress(KeyError):
|
|
740
790
|
del self.__dict__["validated_trading_service"]
|
|
741
791
|
|
|
742
792
|
def can_deny(self):
|
|
743
793
|
errors = dict()
|
|
744
|
-
if self.
|
|
794
|
+
if not self.orders.all().exists():
|
|
745
795
|
errors["non_field_errors"] = [
|
|
746
|
-
_("At least one
|
|
796
|
+
_("At least one order needs to be submitted to be able to deny this proposal")
|
|
747
797
|
]
|
|
748
798
|
return errors
|
|
749
799
|
|
|
@@ -759,7 +809,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
759
809
|
custom={
|
|
760
810
|
"_transition_button": ActionButton(
|
|
761
811
|
method=RequestType.PATCH,
|
|
762
|
-
identifiers=("wbportfolio:
|
|
812
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
763
813
|
icon=WBIcon.UNDO.icon,
|
|
764
814
|
key="backtodraft",
|
|
765
815
|
label="Back to Draft",
|
|
@@ -771,7 +821,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
771
821
|
def backtodraft(self, **kwargs):
|
|
772
822
|
with suppress(KeyError):
|
|
773
823
|
del self.__dict__["validated_trading_service"]
|
|
774
|
-
self.trades.update(status=Trade.Status.DRAFT)
|
|
775
824
|
self.checks.delete()
|
|
776
825
|
|
|
777
826
|
def can_backtodraft(self):
|
|
@@ -787,32 +836,27 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
787
836
|
custom={
|
|
788
837
|
"_transition_button": ActionButton(
|
|
789
838
|
method=RequestType.PATCH,
|
|
790
|
-
identifiers=("wbportfolio:
|
|
839
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
791
840
|
icon=WBIcon.REGENERATE.icon,
|
|
792
841
|
key="revert",
|
|
793
842
|
label="Revert",
|
|
794
843
|
action_label="revert",
|
|
795
|
-
description_fields="<p>Unapply
|
|
844
|
+
description_fields="<p>Unapply orders and move everything back to draft (i.e. The underlying asset positions will change like the orders were never applied)</p>",
|
|
796
845
|
)
|
|
797
846
|
},
|
|
798
847
|
)
|
|
799
848
|
def revert(self, **kwargs):
|
|
800
849
|
with suppress(KeyError):
|
|
801
850
|
del self.__dict__["validated_trading_service"]
|
|
802
|
-
trades = []
|
|
803
851
|
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
804
852
|
is_estimated=True
|
|
805
853
|
) # we delete the existing portfolio as it has been reverted
|
|
806
|
-
for trade in self.trades.all():
|
|
807
|
-
trade.status = Trade.Status.DRAFT
|
|
808
|
-
trades.append(trade)
|
|
809
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
810
854
|
|
|
811
855
|
def can_revert(self):
|
|
812
856
|
errors = dict()
|
|
813
857
|
if not self.portfolio.can_be_rebalanced:
|
|
814
858
|
errors["portfolio"] = [
|
|
815
|
-
_("The portfolio needs to be a model portfolio in order to revert this
|
|
859
|
+
_("The portfolio needs to be a model portfolio in order to revert this order proposal manually")
|
|
816
860
|
]
|
|
817
861
|
return errors
|
|
818
862
|
|
|
@@ -820,11 +864,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
820
864
|
|
|
821
865
|
@classmethod
|
|
822
866
|
def get_endpoint_basename(cls) -> str:
|
|
823
|
-
return "wbportfolio:
|
|
867
|
+
return "wbportfolio:orderproposal"
|
|
824
868
|
|
|
825
869
|
@classmethod
|
|
826
870
|
def get_representation_endpoint(cls) -> str:
|
|
827
|
-
return "wbportfolio:
|
|
871
|
+
return "wbportfolio:orderproposalrepresentation-list"
|
|
828
872
|
|
|
829
873
|
@classmethod
|
|
830
874
|
def get_representation_value_key(cls) -> str:
|
|
@@ -835,26 +879,26 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
835
879
|
return "{{_portfolio.name}} ({{trade_date}})"
|
|
836
880
|
|
|
837
881
|
|
|
838
|
-
@receiver(post_save, sender="wbportfolio.
|
|
839
|
-
def
|
|
840
|
-
# if we have a
|
|
841
|
-
if not raw and instance.status ==
|
|
842
|
-
# we delete all
|
|
843
|
-
instance.
|
|
844
|
-
instance.
|
|
882
|
+
@receiver(post_save, sender="wbportfolio.OrderProposal")
|
|
883
|
+
def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kwargs):
|
|
884
|
+
# if we have a order proposal in a fail state, we ensure that all future existing order proposal are either deleted (automatic one) or set back to draft
|
|
885
|
+
if not raw and instance.status == OrderProposal.Status.FAILED:
|
|
886
|
+
# we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
887
|
+
instance.invalidate_future_order_proposal()
|
|
888
|
+
instance.invalidate_future_order_proposal()
|
|
845
889
|
|
|
846
890
|
|
|
847
891
|
@shared_task(queue="portfolio")
|
|
848
|
-
def replay_as_task(
|
|
849
|
-
|
|
850
|
-
|
|
892
|
+
def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
893
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
894
|
+
order_proposal.replay(**kwargs)
|
|
851
895
|
if user_id:
|
|
852
896
|
user = User.objects.get(id=user_id)
|
|
853
897
|
send_notification(
|
|
854
898
|
code="wbportfolio.portfolio.replay_done",
|
|
855
|
-
title="
|
|
856
|
-
body=f'We’ve successfully replayed your
|
|
899
|
+
title="Order Proposal Replay Completed",
|
|
900
|
+
body=f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.',
|
|
857
901
|
user=user,
|
|
858
902
|
reverse_name="wbportfolio:portfolio-detail",
|
|
859
|
-
reverse_args=[
|
|
903
|
+
reverse_args=[order_proposal.portfolio.id],
|
|
860
904
|
)
|