wbportfolio 1.54.3__py2.py3-none-any.whl → 1.54.4__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/import_export/handlers/trade.py +3 -3
- wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
- wbportfolio/models/asset.py +19 -18
- wbportfolio/models/portfolio.py +100 -90
- wbportfolio/models/transactions/rebalancing.py +22 -12
- wbportfolio/models/transactions/trade_proposals.py +193 -77
- wbportfolio/models/transactions/trades.py +36 -19
- wbportfolio/pms/analytics/portfolio.py +5 -6
- wbportfolio/pms/trading/handler.py +16 -19
- wbportfolio/pms/typing.py +26 -11
- wbportfolio/rebalancing/base.py +12 -1
- wbportfolio/rebalancing/models/equally_weighted.py +10 -13
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +18 -9
- wbportfolio/serializers/transactions/trade_proposals.py +2 -2
- wbportfolio/tests/models/test_portfolios.py +5 -3
- wbportfolio/tests/models/transactions/test_trade_proposals.py +13 -12
- wbportfolio/tests/pms/test_analytics.py +4 -3
- wbportfolio/tests/rebalancing/test_models.py +16 -10
- wbportfolio/viewsets/configs/display/trade_proposals.py +9 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- {wbportfolio-1.54.3.dist-info → wbportfolio-1.54.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.3.dist-info → wbportfolio-1.54.4.dist-info}/RECORD +24 -23
- {wbportfolio-1.54.3.dist-info → wbportfolio-1.54.4.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.3.dist-info → wbportfolio-1.54.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,7 +7,9 @@ from typing import Any, TypeVar
|
|
|
7
7
|
|
|
8
8
|
from celery import shared_task
|
|
9
9
|
from django.core.exceptions import ValidationError
|
|
10
|
-
from django.db import models
|
|
10
|
+
from django.db import DatabaseError, models
|
|
11
|
+
from django.db.models.signals import post_save
|
|
12
|
+
from django.dispatch import receiver
|
|
11
13
|
from django.utils.functional import cached_property
|
|
12
14
|
from django.utils.translation import gettext_lazy as _
|
|
13
15
|
from django_fsm import FSMField, transition
|
|
@@ -27,9 +29,10 @@ from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
|
27
29
|
from wbportfolio.models.roles import PortfolioRole
|
|
28
30
|
from wbportfolio.pms.trading import TradingService
|
|
29
31
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
30
|
-
from wbportfolio.pms.typing import
|
|
32
|
+
from wbportfolio.pms.typing import Position as PositionDTO
|
|
31
33
|
|
|
32
34
|
from ..asset import AssetPosition, AssetPositionIterator
|
|
35
|
+
from ..exceptions import InvalidAnalyticPortfolio
|
|
33
36
|
from .trades import Trade
|
|
34
37
|
|
|
35
38
|
logger = logging.getLogger("pms")
|
|
@@ -109,7 +112,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
109
112
|
"""
|
|
110
113
|
This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
|
|
111
114
|
"""
|
|
112
|
-
target_portfolio = self.
|
|
115
|
+
target_portfolio = self.convert_to_portfolio()
|
|
113
116
|
|
|
114
117
|
return TradingService(
|
|
115
118
|
self.trade_date,
|
|
@@ -123,7 +126,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
123
126
|
try:
|
|
124
127
|
return self.portfolio.assets.filter(date__lt=self.trade_date).latest("date").date
|
|
125
128
|
except AssetPosition.DoesNotExist:
|
|
126
|
-
return
|
|
129
|
+
return self.value_date
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def value_date(self) -> date:
|
|
133
|
+
return (self.trade_date - BDay(1)).date()
|
|
127
134
|
|
|
128
135
|
@property
|
|
129
136
|
def previous_trade_proposal(self) -> SelfTradeProposal | None:
|
|
@@ -162,13 +169,61 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
162
169
|
def __str__(self) -> str:
|
|
163
170
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
164
171
|
|
|
165
|
-
def
|
|
172
|
+
def convert_to_portfolio(self, use_effective: bool = False) -> PortfolioDTO:
|
|
166
173
|
"""
|
|
167
174
|
Data Transfer Object
|
|
168
175
|
Returns:
|
|
169
176
|
DTO trade object
|
|
170
177
|
"""
|
|
171
|
-
|
|
178
|
+
portfolio = {}
|
|
179
|
+
for asset in self.portfolio.assets.filter(date=self.last_effective_date):
|
|
180
|
+
portfolio[asset.underlying_quote] = dict(
|
|
181
|
+
shares=asset._shares,
|
|
182
|
+
weighting=asset.weighting,
|
|
183
|
+
delta_weight=Decimal("0"),
|
|
184
|
+
price=asset._price,
|
|
185
|
+
currency_fx_rate=asset._currency_fx_rate,
|
|
186
|
+
)
|
|
187
|
+
for trade in self.trades.all().annotate_base_info():
|
|
188
|
+
portfolio[trade.underlying_instrument] = dict(
|
|
189
|
+
weighting=trade._previous_weight,
|
|
190
|
+
delta_weight=trade.weighting,
|
|
191
|
+
shares=trade._target_shares if not use_effective else trade._effective_shares,
|
|
192
|
+
price=trade.price,
|
|
193
|
+
currency_fx_rate=trade.currency_fx_rate,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
previous_weights = dict(map(lambda r: (r[0].id, float(r[1]["weighting"])), portfolio.items()))
|
|
197
|
+
try:
|
|
198
|
+
drifted_weights = self.portfolio.get_analytic_portfolio(
|
|
199
|
+
self.value_date, weights=previous_weights
|
|
200
|
+
).get_next_weights()
|
|
201
|
+
except InvalidAnalyticPortfolio:
|
|
202
|
+
drifted_weights = {}
|
|
203
|
+
positions = []
|
|
204
|
+
for instrument, row in portfolio.items():
|
|
205
|
+
weighting = row["weighting"]
|
|
206
|
+
try:
|
|
207
|
+
drift_factor = Decimal(drifted_weights.pop(instrument.id)) / weighting if weighting else Decimal("1")
|
|
208
|
+
except KeyError:
|
|
209
|
+
drift_factor = Decimal("1")
|
|
210
|
+
if not use_effective:
|
|
211
|
+
weighting = weighting * drift_factor + row["delta_weight"]
|
|
212
|
+
positions.append(
|
|
213
|
+
PositionDTO(
|
|
214
|
+
underlying_instrument=instrument.id,
|
|
215
|
+
instrument_type=instrument.instrument_type.id,
|
|
216
|
+
weighting=weighting,
|
|
217
|
+
drift_factor=drift_factor if use_effective else Decimal("1"),
|
|
218
|
+
shares=row["shares"],
|
|
219
|
+
currency=instrument.currency.id,
|
|
220
|
+
date=self.last_effective_date if use_effective else self.trade_date,
|
|
221
|
+
is_cash=instrument.is_cash or instrument.is_cash_equivalent,
|
|
222
|
+
price=row["price"],
|
|
223
|
+
currency_fx_rate=row["currency_fx_rate"],
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
return PortfolioDTO(positions)
|
|
172
227
|
|
|
173
228
|
# Start tools methods
|
|
174
229
|
def _clone(self, **kwargs) -> SelfTradeProposal:
|
|
@@ -208,14 +263,14 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
208
263
|
service = TradingService(
|
|
209
264
|
self.trade_date,
|
|
210
265
|
effective_portfolio=self._get_default_effective_portfolio(),
|
|
211
|
-
target_portfolio=self.
|
|
266
|
+
target_portfolio=self.convert_to_portfolio(),
|
|
212
267
|
total_target_weight=total_target_weight,
|
|
213
268
|
)
|
|
214
269
|
leftovers_trades = self.trades.all()
|
|
215
270
|
for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
|
|
216
271
|
with suppress(Trade.DoesNotExist):
|
|
217
272
|
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
218
|
-
trade.weighting = round(trade_dto.delta_weight,
|
|
273
|
+
trade.weighting = round(trade_dto.delta_weight, Trade.TRADE_WEIGHTING_PRECISION)
|
|
219
274
|
trade.save()
|
|
220
275
|
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
221
276
|
leftovers_trades.delete()
|
|
@@ -235,34 +290,39 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
235
290
|
params.update(rebalancer.parameters)
|
|
236
291
|
params.update(kwargs)
|
|
237
292
|
return self.rebalancing_model.get_target_portfolio(
|
|
238
|
-
self.portfolio, self.trade_date, self.
|
|
293
|
+
self.portfolio, self.trade_date, self.value_date, **params
|
|
239
294
|
)
|
|
240
295
|
if self.trades.exists():
|
|
241
|
-
return self.
|
|
296
|
+
return self.convert_to_portfolio()
|
|
242
297
|
# Return the current portfolio by default
|
|
243
|
-
return self.
|
|
298
|
+
return self.convert_to_portfolio(use_effective=False)
|
|
244
299
|
|
|
245
300
|
def _get_default_effective_portfolio(self):
|
|
246
|
-
return self.
|
|
301
|
+
return self.convert_to_portfolio(use_effective=True)
|
|
247
302
|
|
|
248
303
|
def reset_trades(
|
|
249
304
|
self,
|
|
250
305
|
target_portfolio: PortfolioDTO | None = None,
|
|
306
|
+
effective_portfolio: PortfolioRole | None = None,
|
|
251
307
|
validate_trade: bool = True,
|
|
252
308
|
total_target_weight: Decimal = Decimal("1.0"),
|
|
253
309
|
):
|
|
254
310
|
"""
|
|
255
311
|
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
256
312
|
"""
|
|
313
|
+
if self.rebalancing_model:
|
|
314
|
+
self.trades.all().delete()
|
|
257
315
|
# delete all existing trades
|
|
258
|
-
last_effective_date = self.last_effective_date
|
|
259
316
|
# Get effective and target portfolio
|
|
260
317
|
if not target_portfolio:
|
|
261
318
|
target_portfolio = self._get_default_target_portfolio()
|
|
319
|
+
if not effective_portfolio:
|
|
320
|
+
effective_portfolio = self._get_default_effective_portfolio()
|
|
321
|
+
|
|
262
322
|
if target_portfolio:
|
|
263
323
|
service = TradingService(
|
|
264
324
|
self.trade_date,
|
|
265
|
-
effective_portfolio=
|
|
325
|
+
effective_portfolio=effective_portfolio,
|
|
266
326
|
target_portfolio=target_portfolio,
|
|
267
327
|
total_target_weight=total_target_weight,
|
|
268
328
|
)
|
|
@@ -273,78 +333,109 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
273
333
|
trades = service.trades_batch.trades_map.values()
|
|
274
334
|
for trade_dto in trades:
|
|
275
335
|
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
280
|
-
weighting = round(trade_dto.delta_weight, 6)
|
|
281
|
-
drift_factor = trade_dto.drift_factor
|
|
282
|
-
try:
|
|
283
|
-
trade = self.trades.get(underlying_instrument=instrument)
|
|
284
|
-
trade.weighting = weighting
|
|
285
|
-
trade.currency_fx_rate = currency_fx_rate
|
|
286
|
-
trade.status = Trade.Status.DRAFT
|
|
287
|
-
trade.drift_factor = drift_factor
|
|
288
|
-
except Trade.DoesNotExist:
|
|
289
|
-
trade = Trade(
|
|
290
|
-
underlying_instrument=instrument,
|
|
291
|
-
currency=instrument.currency,
|
|
292
|
-
value_date=last_effective_date,
|
|
293
|
-
transaction_date=self.trade_date,
|
|
294
|
-
trade_proposal=self,
|
|
295
|
-
portfolio=self.portfolio,
|
|
296
|
-
weighting=weighting,
|
|
297
|
-
drift_factor=drift_factor,
|
|
298
|
-
status=Trade.Status.DRAFT,
|
|
299
|
-
currency_fx_rate=currency_fx_rate,
|
|
336
|
+
if not instrument.is_cash: # we do not save trade that includes cash component
|
|
337
|
+
currency_fx_rate = instrument.currency.convert(
|
|
338
|
+
self.value_date, self.portfolio.currency, exact_lookup=True
|
|
300
339
|
)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
341
|
+
weighting = round(trade_dto.delta_weight, Trade.TRADE_WEIGHTING_PRECISION)
|
|
342
|
+
drift_factor = trade_dto.drift_factor
|
|
343
|
+
try:
|
|
344
|
+
trade = self.trades.get(underlying_instrument=instrument)
|
|
345
|
+
trade.weighting = weighting
|
|
346
|
+
trade.currency_fx_rate = currency_fx_rate
|
|
347
|
+
trade.status = Trade.Status.DRAFT
|
|
348
|
+
trade.drift_factor = drift_factor
|
|
349
|
+
except Trade.DoesNotExist:
|
|
350
|
+
trade = Trade(
|
|
351
|
+
underlying_instrument=instrument,
|
|
352
|
+
currency=instrument.currency,
|
|
353
|
+
value_date=self.value_date,
|
|
354
|
+
transaction_date=self.trade_date,
|
|
355
|
+
trade_proposal=self,
|
|
356
|
+
portfolio=self.portfolio,
|
|
357
|
+
weighting=weighting,
|
|
358
|
+
drift_factor=drift_factor,
|
|
359
|
+
status=Trade.Status.DRAFT,
|
|
360
|
+
currency_fx_rate=currency_fx_rate,
|
|
361
|
+
)
|
|
362
|
+
trade.price = trade.get_price()
|
|
363
|
+
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
364
|
+
if trade.price is None:
|
|
365
|
+
trade.price = Decimal("0.0")
|
|
366
|
+
trade.weighting = -trade_dto.effective_weight
|
|
367
|
+
|
|
368
|
+
trade.save()
|
|
369
|
+
|
|
370
|
+
def approve_workflow(
|
|
371
|
+
self,
|
|
372
|
+
approve_automatically: bool = True,
|
|
373
|
+
silent_exception: bool = False,
|
|
374
|
+
force_reset_trade: bool = False,
|
|
375
|
+
**reset_trades_kwargs,
|
|
376
|
+
):
|
|
377
|
+
if self.status == TradeProposal.Status.APPROVED:
|
|
378
|
+
logger.info("Reverting trade proposal ...")
|
|
379
|
+
self.revert()
|
|
380
|
+
if self.status == TradeProposal.Status.DRAFT:
|
|
381
|
+
if (
|
|
382
|
+
self.rebalancing_model or force_reset_trade
|
|
383
|
+
): # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
|
|
384
|
+
logger.info("Resetting trades ...")
|
|
385
|
+
try: # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
|
|
386
|
+
self.reset_trades(**reset_trades_kwargs)
|
|
387
|
+
except (ValidationError, DatabaseError) as e:
|
|
388
|
+
self.status = TradeProposal.Status.FAILED
|
|
389
|
+
if not silent_exception:
|
|
390
|
+
raise ValidationError(e)
|
|
391
|
+
return
|
|
392
|
+
logger.info("Submitting trade proposal ...")
|
|
393
|
+
self.submit()
|
|
394
|
+
if self.status == TradeProposal.Status.SUBMIT:
|
|
395
|
+
logger.info("Approving trade proposal ...")
|
|
396
|
+
if approve_automatically and self.portfolio.can_be_rebalanced:
|
|
397
|
+
self.approve(replay=False)
|
|
398
|
+
|
|
399
|
+
def replay(self, force_reset_trade: bool = False, broadcast_changes_at_date: bool = True):
|
|
309
400
|
last_trade_proposal = self
|
|
310
401
|
last_trade_proposal_created = False
|
|
311
402
|
while last_trade_proposal and last_trade_proposal.status == TradeProposal.Status.APPROVED:
|
|
312
403
|
if not last_trade_proposal_created:
|
|
313
404
|
logger.info(f"Replaying trade proposal {last_trade_proposal}")
|
|
314
405
|
last_trade_proposal.portfolio.assets.filter(
|
|
315
|
-
date=
|
|
316
|
-
).delete() # we delete the existing position and we reapply the trade proposal
|
|
317
|
-
|
|
318
|
-
logger.info("Reverting trade proposal ...")
|
|
319
|
-
last_trade_proposal.revert()
|
|
320
|
-
if last_trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
321
|
-
if self.rebalancing_model: # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
|
|
322
|
-
logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
|
|
323
|
-
with suppress(
|
|
324
|
-
ValidationError
|
|
325
|
-
): # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
|
|
326
|
-
self.reset_trades()
|
|
327
|
-
logger.info("Submitting trade proposal ...")
|
|
328
|
-
last_trade_proposal.submit()
|
|
329
|
-
if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
|
|
330
|
-
logger.info("Approving trade proposal ...")
|
|
331
|
-
last_trade_proposal.approve(replay=False)
|
|
406
|
+
date=self.trade_date
|
|
407
|
+
).all().delete() # we delete the existing position and we reapply the trade proposal
|
|
408
|
+
last_trade_proposal.approve_workflow(silent_exception=True, force_reset_trade=force_reset_trade)
|
|
332
409
|
last_trade_proposal.save()
|
|
410
|
+
if last_trade_proposal.status != TradeProposal.Status.APPROVED:
|
|
411
|
+
break
|
|
333
412
|
next_trade_proposal = last_trade_proposal.next_trade_proposal
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
413
|
+
if next_trade_proposal:
|
|
414
|
+
next_trade_date = next_trade_proposal.trade_date - timedelta(days=1)
|
|
415
|
+
elif next_expected_rebalancing_date := self.portfolio.get_next_rebalancing_date(
|
|
416
|
+
last_trade_proposal.trade_date
|
|
417
|
+
):
|
|
418
|
+
next_trade_date = (
|
|
419
|
+
next_expected_rebalancing_date + timedelta(days=7)
|
|
420
|
+
) # we don't know yet if rebalancing is valid and can be executed on `next_expected_rebalancing_date`, so we add safety window of 7 days
|
|
421
|
+
else:
|
|
422
|
+
next_trade_date = date.today()
|
|
423
|
+
next_trade_date = min(next_trade_date, date.today())
|
|
338
424
|
positions, overriding_trade_proposal = self.portfolio.drift_weights(
|
|
339
|
-
last_trade_proposal.trade_date, next_trade_date
|
|
425
|
+
last_trade_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
|
|
340
426
|
)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
427
|
+
|
|
428
|
+
# self.portfolio.assets.filter(
|
|
429
|
+
# date__gt=last_trade_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
|
|
430
|
+
# ).update(
|
|
431
|
+
# is_estimated=True
|
|
432
|
+
# ) # ensure that we reset non estimated position leftover to estimated between trade proposal during replay
|
|
346
433
|
self.portfolio.bulk_create_positions(
|
|
347
|
-
positions,
|
|
434
|
+
positions,
|
|
435
|
+
delete_leftovers=True,
|
|
436
|
+
compute_metrics=False,
|
|
437
|
+
broadcast_changes_at_date=broadcast_changes_at_date,
|
|
438
|
+
evaluate_rebalancer=False,
|
|
348
439
|
)
|
|
349
440
|
if overriding_trade_proposal:
|
|
350
441
|
last_trade_proposal_created = True
|
|
@@ -353,7 +444,20 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
353
444
|
last_trade_proposal_created = False
|
|
354
445
|
last_trade_proposal = next_trade_proposal
|
|
355
446
|
|
|
356
|
-
def
|
|
447
|
+
def invalidate_future_trade_proposal(self):
|
|
448
|
+
# Delete all future automatic trade proposals and set the manual one into a draft state
|
|
449
|
+
self.portfolio.trade_proposals.filter(
|
|
450
|
+
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
451
|
+
).delete()
|
|
452
|
+
for future_trade_proposal in self.portfolio.trade_proposals.filter(
|
|
453
|
+
trade_date__gt=self.trade_date, status=TradeProposal.Status.APPROVED
|
|
454
|
+
):
|
|
455
|
+
future_trade_proposal.revert()
|
|
456
|
+
future_trade_proposal.save()
|
|
457
|
+
|
|
458
|
+
def get_estimated_shares(
|
|
459
|
+
self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal
|
|
460
|
+
) -> Decimal | None:
|
|
357
461
|
"""
|
|
358
462
|
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
359
463
|
|
|
@@ -574,7 +678,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
574
678
|
|
|
575
679
|
Trade.objects.bulk_update(trades, ["status"])
|
|
576
680
|
self.portfolio.bulk_create_positions(
|
|
577
|
-
AssetPositionIterator(self.portfolio).add(assets
|
|
681
|
+
AssetPositionIterator(self.portfolio).add(assets, is_estimated=False),
|
|
682
|
+
evaluate_rebalancer=False,
|
|
683
|
+
force_save=True,
|
|
578
684
|
)
|
|
579
685
|
if replay and self.portfolio.is_manageable:
|
|
580
686
|
replay_as_task.delay(self.id, user_id=by.id if by else None)
|
|
@@ -687,8 +793,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
687
793
|
).delete() # we delete the existing portfolio as it has been reverted
|
|
688
794
|
for trade in self.trades.all():
|
|
689
795
|
trade.status = Trade.Status.DRAFT
|
|
796
|
+
trade.drift_factor = Decimal("1")
|
|
690
797
|
trades.append(trade)
|
|
691
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
798
|
+
Trade.objects.bulk_update(trades, ["status", "drift_factor"])
|
|
692
799
|
|
|
693
800
|
def can_revert(self):
|
|
694
801
|
errors = dict()
|
|
@@ -717,6 +824,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
717
824
|
return "{{_portfolio.name}} ({{trade_date}})"
|
|
718
825
|
|
|
719
826
|
|
|
827
|
+
@receiver(post_save, sender="wbportfolio.TradeProposal")
|
|
828
|
+
def post_fail_trade_proposal(sender, instance: TradeProposal, created, raw, **kwargs):
|
|
829
|
+
# if we have a trade proposal in a fail state, we ensure that all future existing trade proposal are either deleted (automatic one) or set back to draft
|
|
830
|
+
if not raw and instance.status == TradeProposal.Status.FAILED:
|
|
831
|
+
# we delete all trade proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
832
|
+
instance.invalidate_future_trade_proposal()
|
|
833
|
+
instance.invalidate_future_trade_proposal()
|
|
834
|
+
|
|
835
|
+
|
|
720
836
|
@shared_task(queue="portfolio")
|
|
721
837
|
def replay_as_task(trade_proposal_id, user_id: int | None = None):
|
|
722
838
|
trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
|
|
@@ -53,7 +53,7 @@ class TradeQueryset(OrderedModelQuerySet):
|
|
|
53
53
|
.order_by("-date")
|
|
54
54
|
.values("date")[:1]
|
|
55
55
|
),
|
|
56
|
-
|
|
56
|
+
previous_weight=Coalesce(
|
|
57
57
|
Subquery(
|
|
58
58
|
AssetPosition.unannotated_objects.filter(
|
|
59
59
|
underlying_quote=OuterRef("underlying_instrument"),
|
|
@@ -66,8 +66,10 @@ class TradeQueryset(OrderedModelQuerySet):
|
|
|
66
66
|
),
|
|
67
67
|
Decimal(0),
|
|
68
68
|
),
|
|
69
|
-
effective_weight=Round(
|
|
70
|
-
|
|
69
|
+
effective_weight=Round(
|
|
70
|
+
F("previous_weight") * F("drift_factor"), precision=Trade.TRADE_WEIGHTING_PRECISION
|
|
71
|
+
),
|
|
72
|
+
target_weight=Round(F("effective_weight") + F("weighting"), precision=Trade.TRADE_WEIGHTING_PRECISION),
|
|
71
73
|
effective_shares=Coalesce(
|
|
72
74
|
Subquery(
|
|
73
75
|
AssetPosition.objects.filter(
|
|
@@ -125,6 +127,9 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
125
127
|
import_export_handler_class = TradeImportHandler
|
|
126
128
|
|
|
127
129
|
TRADE_WINDOW_INTERVAL = 7
|
|
130
|
+
TRADE_WEIGHTING_PRECISION = (
|
|
131
|
+
8 # we need to match the assetposition weighting. Skfolio advices using a even smaller number (5)
|
|
132
|
+
)
|
|
128
133
|
|
|
129
134
|
class Status(models.TextChoices):
|
|
130
135
|
DRAFT = "DRAFT", "Draft"
|
|
@@ -165,7 +170,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
165
170
|
|
|
166
171
|
weighting = models.DecimalField(
|
|
167
172
|
max_digits=9,
|
|
168
|
-
decimal_places=
|
|
173
|
+
decimal_places=TRADE_WEIGHTING_PRECISION,
|
|
169
174
|
default=Decimal(0),
|
|
170
175
|
help_text="The weight to be multiplied against the target",
|
|
171
176
|
verbose_name="Weight",
|
|
@@ -226,7 +231,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
226
231
|
)
|
|
227
232
|
drift_factor = models.DecimalField(
|
|
228
233
|
max_digits=16,
|
|
229
|
-
decimal_places=
|
|
234
|
+
decimal_places=TRADE_WEIGHTING_PRECISION,
|
|
230
235
|
default=Decimal(1.0),
|
|
231
236
|
verbose_name="Drift Factor",
|
|
232
237
|
help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
|
|
@@ -324,7 +329,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
324
329
|
# we fall back to the latest price before t0
|
|
325
330
|
return (
|
|
326
331
|
InstrumentPrice.objects.filter_only_valid_prices()
|
|
327
|
-
.filter(instrument=self.underlying_instrument, date__lte=self.
|
|
332
|
+
.filter(instrument=self.underlying_instrument, date__lte=self.transaction_date)
|
|
328
333
|
.latest("date")
|
|
329
334
|
)
|
|
330
335
|
|
|
@@ -468,7 +473,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
468
473
|
return self.last_effective_date
|
|
469
474
|
elif (
|
|
470
475
|
assets := AssetPosition.unannotated_objects.filter(
|
|
471
|
-
underlying_quote=self.underlying_instrument,
|
|
472
476
|
date__lte=self.value_date,
|
|
473
477
|
portfolio=self.portfolio,
|
|
474
478
|
)
|
|
@@ -477,15 +481,21 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
477
481
|
|
|
478
482
|
@property
|
|
479
483
|
@admin.display(description="Effective Weight")
|
|
480
|
-
def
|
|
481
|
-
if hasattr(self, "
|
|
482
|
-
return self.
|
|
483
|
-
|
|
484
|
+
def _previous_weight(self) -> Decimal:
|
|
485
|
+
if hasattr(self, "previous_weight"):
|
|
486
|
+
return self.previous_weight
|
|
487
|
+
return AssetPosition.unannotated_objects.filter(
|
|
484
488
|
underlying_quote=self.underlying_instrument,
|
|
485
489
|
date=self._last_effective_date,
|
|
486
490
|
portfolio=self.portfolio,
|
|
487
491
|
).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
|
|
488
|
-
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
@admin.display(description="Effective Weight")
|
|
495
|
+
def _effective_weight(self) -> Decimal:
|
|
496
|
+
if hasattr(self, "effective_weight"):
|
|
497
|
+
return self.effective_weight
|
|
498
|
+
return round(self._previous_weight * self.drift_factor, self.TRADE_WEIGHTING_PRECISION)
|
|
489
499
|
|
|
490
500
|
@property
|
|
491
501
|
@admin.display(description="Effective Shares")
|
|
@@ -504,7 +514,9 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
504
514
|
@property
|
|
505
515
|
@admin.display(description="Target Weight")
|
|
506
516
|
def _target_weight(self) -> Decimal:
|
|
507
|
-
return getattr(
|
|
517
|
+
return getattr(
|
|
518
|
+
self, "target_weight", round(self._effective_weight + self.weighting, self.TRADE_WEIGHTING_PRECISION)
|
|
519
|
+
)
|
|
508
520
|
|
|
509
521
|
@property
|
|
510
522
|
@admin.display(description="Target Shares")
|
|
@@ -553,6 +565,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
553
565
|
self._set_type()
|
|
554
566
|
|
|
555
567
|
def save(self, *args, **kwargs):
|
|
568
|
+
if abs(self.weighting) < 10e-6:
|
|
569
|
+
self.weighting = Decimal("0")
|
|
556
570
|
if self.trade_proposal:
|
|
557
571
|
if not self.underlying_instrument.is_investable_universe:
|
|
558
572
|
self.underlying_instrument.is_investable_universe = True
|
|
@@ -634,9 +648,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
634
648
|
return asset
|
|
635
649
|
|
|
636
650
|
def get_price(self) -> Decimal | None:
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return Decimal(self.underlying_instrument.get_price(self.value_date))
|
|
651
|
+
with suppress(ValueError):
|
|
652
|
+
return Decimal.from_float(self.underlying_instrument.get_price(self.transaction_date))
|
|
640
653
|
|
|
641
654
|
def delete(self, **kwargs):
|
|
642
655
|
pre_collection.send(sender=self.__class__, instance=self)
|
|
@@ -646,19 +659,23 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
646
659
|
ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
|
|
647
660
|
return f"{ticker}{self.shares} ({self.bank})"
|
|
648
661
|
|
|
649
|
-
def _build_dto(self) -> TradeDTO:
|
|
662
|
+
def _build_dto(self, drift_factor: Decimal = None) -> TradeDTO:
|
|
650
663
|
"""
|
|
651
664
|
Data Transfer Object
|
|
652
665
|
Returns:
|
|
653
666
|
DTO trade object
|
|
667
|
+
|
|
654
668
|
"""
|
|
669
|
+
if not drift_factor:
|
|
670
|
+
drift_factor = self.drift_factor
|
|
655
671
|
return TradeDTO(
|
|
656
672
|
id=self.id,
|
|
657
673
|
underlying_instrument=self.underlying_instrument.id,
|
|
658
|
-
|
|
659
|
-
target_weight=self.
|
|
674
|
+
previous_weight=self._previous_weight,
|
|
675
|
+
target_weight=self._previous_weight * drift_factor + self.weighting,
|
|
660
676
|
effective_shares=self._effective_shares,
|
|
661
677
|
target_shares=self._target_shares,
|
|
678
|
+
drift_factor=drift_factor,
|
|
662
679
|
currency_fx_rate=self.currency_fx_rate,
|
|
663
680
|
price=self.price,
|
|
664
681
|
instrument_type=self.underlying_instrument.security_instrument_type.id,
|
|
@@ -25,12 +25,11 @@ class Portfolio(BasePortfolio):
|
|
|
25
25
|
"""
|
|
26
26
|
returns = self.X.iloc[-1, :].T
|
|
27
27
|
weights = self.all_weights_per_observation.iloc[-1, :].T
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return contribution.dropna().to_dict()
|
|
28
|
+
portfolio_returns = (weights * (returns + 1.0)).sum()
|
|
29
|
+
next_weights = weights * (returns + 1.0) / portfolio_returns
|
|
30
|
+
next_weights = next_weights.dropna()
|
|
31
|
+
next_weights = next_weights / next_weights.sum()
|
|
32
|
+
return next_weights.to_dict()
|
|
34
33
|
|
|
35
34
|
def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
|
|
36
35
|
expected_returns = self.weights @ self.X.iloc[-1, :].T
|
|
@@ -86,7 +86,6 @@ class TradingService:
|
|
|
86
86
|
self.trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio).normalize(
|
|
87
87
|
total_target_weight
|
|
88
88
|
)
|
|
89
|
-
|
|
90
89
|
self._effective_portfolio = effective_portfolio
|
|
91
90
|
self._target_portfolio = target_portfolio
|
|
92
91
|
|
|
@@ -144,34 +143,32 @@ class TradingService:
|
|
|
144
143
|
trades: list[Trade] = []
|
|
145
144
|
for instrument_id, pos in instruments.items():
|
|
146
145
|
if not pos.is_cash:
|
|
147
|
-
|
|
146
|
+
previous_weight = target_weight = 0
|
|
148
147
|
effective_shares = target_shares = 0
|
|
149
148
|
drift_factor = 1.0
|
|
150
149
|
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
151
|
-
|
|
150
|
+
previous_weight = effective_pos.weighting
|
|
152
151
|
effective_shares = effective_pos.shares
|
|
153
152
|
drift_factor = effective_pos.drift_factor
|
|
154
153
|
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
155
154
|
target_weight = target_pos.weighting
|
|
156
155
|
if target_pos.shares is not None:
|
|
157
156
|
target_shares = target_pos.shares
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
171
|
-
drift_factor=Decimal(drift_factor),
|
|
172
|
-
)
|
|
157
|
+
trade = Trade(
|
|
158
|
+
underlying_instrument=instrument_id,
|
|
159
|
+
previous_weight=previous_weight,
|
|
160
|
+
target_weight=target_weight,
|
|
161
|
+
effective_shares=effective_shares,
|
|
162
|
+
target_shares=target_shares,
|
|
163
|
+
date=self.trade_date,
|
|
164
|
+
instrument_type=pos.instrument_type,
|
|
165
|
+
currency=pos.currency,
|
|
166
|
+
price=Decimal(pos.price) if pos.price is not None else Decimal("0"),
|
|
167
|
+
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
168
|
+
drift_factor=Decimal(drift_factor),
|
|
173
169
|
)
|
|
174
|
-
|
|
170
|
+
trades.append(trade)
|
|
171
|
+
return TradeBatch(trades)
|
|
175
172
|
|
|
176
173
|
def is_valid(self, ignore_error: bool = False) -> bool:
|
|
177
174
|
"""
|