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