wbportfolio 1.54.14__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.

Files changed (86) hide show
  1. wbportfolio/admin/__init__.py +2 -0
  2. wbportfolio/admin/orders/__init__.py +2 -0
  3. wbportfolio/admin/orders/order_proposals.py +14 -0
  4. wbportfolio/admin/orders/orders.py +30 -0
  5. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  6. wbportfolio/admin/transactions/__init__.py +0 -1
  7. wbportfolio/admin/transactions/trades.py +2 -17
  8. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  9. wbportfolio/factories/__init__.py +2 -1
  10. wbportfolio/factories/orders/__init__.py +2 -0
  11. wbportfolio/factories/orders/order_proposals.py +17 -0
  12. wbportfolio/factories/orders/orders.py +21 -0
  13. wbportfolio/factories/rebalancing.py +1 -1
  14. wbportfolio/factories/trades.py +2 -13
  15. wbportfolio/filters/orders/__init__.py +1 -0
  16. wbportfolio/filters/orders/orders.py +11 -0
  17. wbportfolio/import_export/handlers/trade.py +20 -20
  18. wbportfolio/import_export/resources/trades.py +2 -2
  19. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  20. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  21. wbportfolio/models/__init__.py +2 -0
  22. wbportfolio/models/orders/__init__.py +2 -0
  23. wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +288 -244
  24. wbportfolio/models/orders/orders.py +243 -0
  25. wbportfolio/models/portfolio.py +16 -19
  26. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
  27. wbportfolio/models/transactions/__init__.py +0 -2
  28. wbportfolio/models/transactions/trades.py +10 -450
  29. wbportfolio/pms/analytics/portfolio.py +10 -6
  30. wbportfolio/pms/analytics/utils.py +9 -0
  31. wbportfolio/pms/trading/handler.py +6 -4
  32. wbportfolio/pms/typing.py +18 -7
  33. wbportfolio/rebalancing/decorators.py +1 -1
  34. wbportfolio/rebalancing/models/composite.py +3 -7
  35. wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
  36. wbportfolio/serializers/__init__.py +1 -0
  37. wbportfolio/serializers/orders/__init__.py +2 -0
  38. wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +23 -15
  39. wbportfolio/serializers/orders/orders.py +187 -0
  40. wbportfolio/serializers/portfolios.py +7 -7
  41. wbportfolio/serializers/rebalancing.py +1 -1
  42. wbportfolio/serializers/transactions/__init__.py +1 -5
  43. wbportfolio/serializers/transactions/trades.py +1 -182
  44. wbportfolio/tests/conftest.py +4 -2
  45. wbportfolio/tests/models/orders/__init__.py +0 -0
  46. wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +218 -246
  47. wbportfolio/tests/models/test_portfolios.py +11 -10
  48. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  49. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  50. wbportfolio/tests/rebalancing/test_models.py +24 -28
  51. wbportfolio/tests/signals.py +10 -10
  52. wbportfolio/tests/tests.py +1 -1
  53. wbportfolio/urls.py +7 -7
  54. wbportfolio/viewsets/__init__.py +2 -0
  55. wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
  56. wbportfolio/viewsets/configs/buttons/trades.py +0 -8
  57. wbportfolio/viewsets/configs/display/__init__.py +0 -2
  58. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  59. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  60. wbportfolio/viewsets/configs/display/trades.py +1 -225
  61. wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
  62. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  63. wbportfolio/viewsets/orders/__init__.py +6 -0
  64. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  65. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  66. wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +21 -21
  67. wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
  68. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  69. wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
  70. wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
  71. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  72. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  73. wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
  74. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  75. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  76. wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -46
  77. wbportfolio/viewsets/orders/orders.py +219 -0
  78. wbportfolio/viewsets/portfolios.py +12 -12
  79. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  80. wbportfolio/viewsets/transactions/__init__.py +1 -7
  81. wbportfolio/viewsets/transactions/trades.py +1 -199
  82. {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.15.dist-info}/METADATA +1 -1
  83. {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.15.dist-info}/RECORD +85 -58
  84. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  85. {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.15.dist-info}/WHEEL +0 -0
  86. {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.15.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,7 @@
1
- from contextlib import suppress
2
- from datetime import date, timedelta
1
+ from datetime import timedelta
3
2
  from decimal import Decimal
4
3
 
5
4
  from celery import shared_task
6
- from django.contrib import admin
7
5
  from django.db import models
8
6
  from django.db.models import (
9
7
  Case,
@@ -16,17 +14,10 @@ from django.db.models import (
16
14
  Sum,
17
15
  When,
18
16
  )
19
- from django.db.models.functions import Coalesce, Round
17
+ from django.db.models.functions import Coalesce
20
18
  from django.db.models.signals import post_save
21
19
  from django.dispatch import receiver
22
- from django.utils.functional import cached_property
23
- from django.utils.translation import gettext_lazy as _
24
- from django_fsm import GET_STATE, FSMField, transition
25
- from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet
26
- from wbcore.contrib.icons import WBIcon
27
20
  from wbcore.contrib.io.mixins import ImportMixin
28
- from wbcore.enums import RequestType
29
- from wbcore.metadata.configs.buttons import ActionButton
30
21
  from wbcore.signals import pre_merge
31
22
  from wbcore.signals.models import pre_collection
32
23
  from wbfdm.models import Instrument
@@ -34,76 +25,12 @@ from wbfdm.models.instruments.instrument_prices import InstrumentPrice
34
25
  from wbfdm.signals import add_instrument_to_investable_universe
35
26
 
36
27
  from wbportfolio.import_export.handlers.trade import TradeImportHandler
37
- from wbportfolio.models.asset import AssetPosition
38
28
  from wbportfolio.models.custodians import Custodian
39
- from wbportfolio.models.roles import PortfolioRole
40
- from wbportfolio.pms.typing import Trade as TradeDTO
41
29
 
42
30
  from .transactions import TransactionMixin
43
31
 
44
32
 
45
- class TradeQueryset(OrderedModelQuerySet):
46
- def annotate_base_info(self):
47
- return self.annotate(
48
- last_effective_date=Subquery(
49
- AssetPosition.unannotated_objects.filter(
50
- date__lte=OuterRef("value_date"),
51
- portfolio=OuterRef("portfolio"),
52
- )
53
- .order_by("-date")
54
- .values("date")[:1]
55
- ),
56
- previous_weight=Coalesce(
57
- Subquery(
58
- AssetPosition.unannotated_objects.filter(
59
- underlying_quote=OuterRef("underlying_instrument"),
60
- date=OuterRef("last_effective_date"),
61
- portfolio=OuterRef("portfolio"),
62
- )
63
- .values("portfolio")
64
- .annotate(s=Sum("weighting"))
65
- .values("s")[:1]
66
- ),
67
- Decimal(0),
68
- ),
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),
73
- effective_shares=Coalesce(
74
- Subquery(
75
- AssetPosition.objects.filter(
76
- underlying_quote=OuterRef("underlying_instrument"),
77
- date=OuterRef("last_effective_date"),
78
- portfolio=OuterRef("portfolio"),
79
- )
80
- .values("portfolio")
81
- .annotate(s=Sum("shares"))
82
- .values("s")[:1]
83
- ),
84
- Decimal(0),
85
- ),
86
- target_shares=F("effective_shares") + F("shares"),
87
- )
88
-
89
-
90
- class DefaultTradeManager(OrderedModelManager):
91
- """This manager is expect to be the trade default manager and annotate by default the effective weight (extracted
92
- from the associated portfolio) and the target weight as an addition between the effective weight and the delta weight
93
- """
94
-
95
- def __init__(self, with_annotation: bool = False, *args, **kwargs):
96
- self.with_annotation = with_annotation
97
- super().__init__(*args, **kwargs)
98
-
99
- def get_queryset(self) -> TradeQueryset:
100
- qs = TradeQueryset(self.model, using=self._db)
101
- if self.with_annotation:
102
- qs = qs.annotate_base_info()
103
- return qs
104
-
105
-
106
- class ValidCustomerTradeManager(DefaultTradeManager):
33
+ class ValidCustomerTradeManager(models.Manager):
107
34
  def __init__(self, without_internal_trade: bool = False):
108
35
  self.without_internal_trade = without_internal_trade
109
36
  super().__init__()
@@ -123,20 +50,10 @@ class ValidCustomerTradeManager(DefaultTradeManager):
123
50
  return qs
124
51
 
125
52
 
126
- class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
53
+ class Trade(TransactionMixin, ImportMixin, models.Model):
127
54
  import_export_handler_class = TradeImportHandler
128
55
 
129
56
  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
- )
133
-
134
- class Status(models.TextChoices):
135
- DRAFT = "DRAFT", "Draft"
136
- SUBMIT = "SUBMIT", "Submit"
137
- EXECUTED = "EXECUTED", "Executed"
138
- CONFIRMED = "CONFIRMED", "Confirmed"
139
- FAILED = "FAILED", "Failed"
140
57
 
141
58
  class Type(models.TextChoices):
142
59
  REBALANCE = "REBALANCE", "Rebalance"
@@ -151,7 +68,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
151
68
  transaction_subtype = models.CharField(
152
69
  max_length=32, default=Type.BUY, choices=Type.choices, verbose_name="Trade Type"
153
70
  )
154
- status = FSMField(default=Status.CONFIRMED, choices=Status.choices, verbose_name="Status")
155
71
  transaction_date = models.DateField(
156
72
  verbose_name="Trade Date",
157
73
  help_text="The date that this transaction was traded.",
@@ -170,7 +86,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
170
86
 
171
87
  weighting = models.DecimalField(
172
88
  max_digits=9,
173
- decimal_places=TRADE_WEIGHTING_PRECISION,
89
+ decimal_places=8,
174
90
  default=Decimal(0),
175
91
  help_text="The weight to be multiplied against the target",
176
92
  verbose_name="Weight",
@@ -221,23 +137,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
221
137
  related_name="trades",
222
138
  on_delete=models.PROTECT,
223
139
  )
224
- trade_proposal = models.ForeignKey(
225
- to="wbportfolio.TradeProposal",
226
- null=True,
227
- blank=True,
228
- related_name="trades",
229
- on_delete=models.CASCADE,
230
- help_text="The Trade Proposal this trade is coming from",
231
- )
232
- drift_factor = models.DecimalField(
233
- max_digits=TRADE_WEIGHTING_PRECISION * 2
234
- + 3, # we don't expect any drift factor to be in the order of magnitude greater than 1000
235
- decimal_places=TRADE_WEIGHTING_PRECISION
236
- * 2, # we need a higher precision for this factor to avoid float inprecision
237
- default=Decimal(1.0),
238
- verbose_name="Drift Factor",
239
- help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
240
- )
241
140
  external_id = models.CharField(
242
141
  max_length=255,
243
142
  null=True,
@@ -254,211 +153,10 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
254
153
  )
255
154
 
256
155
  # Manager
257
- objects = DefaultTradeManager()
258
- annotated_objects = DefaultTradeManager(with_annotation=True)
156
+ objects = models.Manager()
259
157
  valid_customer_trade_objects = ValidCustomerTradeManager()
260
158
  valid_external_customer_trade_objects = ValidCustomerTradeManager(without_internal_trade=True)
261
159
 
262
- @transition(
263
- field=status,
264
- source=Status.DRAFT,
265
- target=GET_STATE(
266
- lambda self, **kwargs: (self.Status.SUBMIT if self.price else self.Status.FAILED),
267
- states=[Status.SUBMIT, Status.FAILED],
268
- ),
269
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
270
- user.profile, portfolio=instance.portfolio
271
- ),
272
- custom={
273
- "_transition_button": ActionButton(
274
- method=RequestType.PATCH,
275
- identifiers=("wbportfolio:trade",),
276
- icon=WBIcon.SEND.icon,
277
- key="submit",
278
- label="Submit",
279
- action_label="Submit",
280
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
281
- )
282
- },
283
- on_error="FAILED",
284
- )
285
- def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
286
- warnings = []
287
- # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
288
- if self.trade_proposal and not self.portfolio.only_weighting:
289
- shares = self.trade_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
290
- if shares != self.shares:
291
- warnings.append(
292
- f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
293
- )
294
- shares = round(shares) # ensure fractional shares are converted into integer
295
- # we need to recompute the delta weight has we changed the number of shares
296
- if shares != self.shares:
297
- self.shares = shares
298
- if portfolio_total_asset_value:
299
- self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
300
-
301
- if not self.price:
302
- warnings.append(
303
- f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
304
- )
305
- return warnings
306
-
307
- def can_submit(self):
308
- pass
309
-
310
- @transition(
311
- field=status,
312
- source=Status.DRAFT,
313
- target=Status.FAILED,
314
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
315
- user.profile, portfolio=instance.portfolio
316
- ),
317
- )
318
- def fail(self, **kwargs):
319
- pass
320
-
321
- # TODO To be removed
322
- @cached_property
323
- def last_underlying_quote_price(self) -> InstrumentPrice | None:
324
- try:
325
- # we try t0 first
326
- return InstrumentPrice.objects.filter_only_valid_prices().get(
327
- instrument=self.underlying_instrument, date=self.transaction_date
328
- )
329
- except InstrumentPrice.DoesNotExist:
330
- with suppress(InstrumentPrice.DoesNotExist):
331
- # we fall back to the latest price before t0
332
- return (
333
- InstrumentPrice.objects.filter_only_valid_prices()
334
- .filter(instrument=self.underlying_instrument, date__lte=self.transaction_date)
335
- .latest("date")
336
- )
337
-
338
- @transition(
339
- field=status,
340
- source=Status.SUBMIT,
341
- target=Status.EXECUTED,
342
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
343
- user.profile, portfolio=instance.portfolio
344
- ),
345
- custom={
346
- "_transition_button": ActionButton(
347
- method=RequestType.PATCH,
348
- identifiers=("wbportfolio:trade",),
349
- icon=WBIcon.CONFIRM.icon,
350
- key="execute",
351
- label="Execute",
352
- action_label="Execute",
353
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
354
- )
355
- },
356
- )
357
- def execute(self, **kwargs):
358
- with suppress(ValueError):
359
- asset = self.get_asset()
360
- AssetPosition.unannotated_objects.update_or_create(
361
- underlying_quote=asset.underlying_quote,
362
- portfolio_created=asset.portfolio_created,
363
- portfolio=asset.portfolio,
364
- date=asset.date,
365
- defaults={
366
- "initial_currency_fx_rate": asset.initial_currency_fx_rate,
367
- "initial_price": asset.initial_price,
368
- "initial_shares": asset.initial_shares,
369
- "underlying_quote_price": asset.underlying_quote_price,
370
- "asset_valuation_date": asset.asset_valuation_date,
371
- "currency": asset.currency,
372
- "is_estimated": asset.is_estimated,
373
- "weighting": asset.weighting,
374
- },
375
- )
376
-
377
- def can_execute(self):
378
- if not self.price:
379
- return {"underlying_instrument": [_("Cannot execute a trade without a valid quote price")]}
380
- if not self.portfolio.is_manageable:
381
- return {
382
- "portfolio": [_("The portfolio needs to be a model portfolio in order to execute this trade manually")]
383
- }
384
-
385
- @transition(
386
- field=status,
387
- source=Status.EXECUTED,
388
- target=Status.CONFIRMED,
389
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
390
- user.profile, portfolio=instance.portfolio
391
- ),
392
- custom={
393
- "_transition_button": ActionButton(
394
- method=RequestType.PATCH,
395
- identifiers=("wbportfolio:trade",),
396
- icon=WBIcon.CONFIRM.icon,
397
- key="confirm",
398
- label="Confirm",
399
- action_label="Confirme",
400
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
401
- )
402
- },
403
- )
404
- def confirm(self, by=None, description=None, **kwargs):
405
- pass
406
-
407
- def can_confirm(self):
408
- pass
409
-
410
- @transition(
411
- field=status,
412
- source=Status.SUBMIT,
413
- target=Status.DRAFT,
414
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
415
- user.profile, portfolio=instance.portfolio
416
- ),
417
- custom={
418
- "_transition_button": ActionButton(
419
- method=RequestType.PATCH,
420
- identifiers=("wbportfolio:trade",),
421
- icon=WBIcon.UNDO.icon,
422
- key="backtodraft",
423
- label="Back to Draft",
424
- action_label="backtodraft",
425
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
426
- )
427
- },
428
- )
429
- def backtodraft(self, **kwargs):
430
- pass
431
-
432
- @transition(
433
- field=status,
434
- source=Status.EXECUTED,
435
- target=Status.DRAFT,
436
- permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
437
- user.profile, portfolio=instance.portfolio
438
- ),
439
- custom={
440
- "_transition_button": ActionButton(
441
- method=RequestType.PATCH,
442
- identifiers=("wbportfolio:trade",),
443
- icon=WBIcon.UNDO.icon,
444
- key="revert",
445
- label="Revert",
446
- action_label="revert",
447
- # description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
448
- )
449
- },
450
- )
451
- def revert(self, to_date=None, **kwargs):
452
- with suppress(AssetPosition.DoesNotExist):
453
- asset = AssetPosition.unannotated_objects.get(
454
- underlying_quote=self.underlying_instrument,
455
- portfolio=self.portfolio,
456
- date=self.transaction_date,
457
- is_estimated=False,
458
- )
459
- asset.set_weighting(asset.weighting - self.weighting)
460
- asset.save()
461
-
462
160
  @property
463
161
  def product(self):
464
162
  from wbportfolio.models.products import Product
@@ -468,77 +166,12 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
468
166
  except Product.DoesNotExist:
469
167
  return None
470
168
 
471
- @property
472
- @admin.display(description="Last Effective Date")
473
- def _last_effective_date(self) -> date:
474
- if hasattr(self, "last_effective_date"):
475
- return self.last_effective_date
476
- elif (
477
- assets := AssetPosition.unannotated_objects.filter(
478
- date__lte=self.value_date,
479
- portfolio=self.portfolio,
480
- )
481
- ).exists():
482
- return assets.latest("date").date
483
-
484
- @property
485
- @admin.display(description="Effective Weight")
486
- def _previous_weight(self) -> Decimal:
487
- if hasattr(self, "previous_weight"):
488
- return self.previous_weight
489
- return AssetPosition.unannotated_objects.filter(
490
- underlying_quote=self.underlying_instrument,
491
- date=self._last_effective_date,
492
- portfolio=self.portfolio,
493
- ).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
494
-
495
- @property
496
- @admin.display(description="Effective Weight")
497
- def _effective_weight(self) -> Decimal:
498
- if hasattr(self, "effective_weight"):
499
- return self.effective_weight
500
- return round(self._previous_weight * self.drift_factor, self.TRADE_WEIGHTING_PRECISION)
501
-
502
- @property
503
- @admin.display(description="Effective Shares")
504
- def _effective_shares(self) -> Decimal:
505
- return getattr(
506
- self,
507
- "effective_shares",
508
- AssetPosition.objects.filter(
509
- underlying_quote=self.underlying_instrument,
510
- date=self.transaction_date,
511
- portfolio=self.portfolio,
512
- ).aggregate(s=Sum("shares"))["s"]
513
- or Decimal(0),
514
- )
515
-
516
- @property
517
- @admin.display(description="Target Weight")
518
- def _target_weight(self) -> Decimal:
519
- return getattr(
520
- self, "target_weight", round(self._effective_weight + self.weighting, self.TRADE_WEIGHTING_PRECISION)
521
- )
522
-
523
- @_target_weight.setter
524
- def _target_weight(self, target_weight):
525
- self.weighting = Decimal(target_weight) - self._effective_weight
526
- self._set_type()
527
-
528
- @property
529
- @admin.display(description="Target Shares")
530
- def _target_shares(self) -> Decimal:
531
- return getattr(self, "target_shares", self._effective_shares + self.shares)
532
-
533
- order_with_respect_to = "trade_proposal"
534
-
535
- class Meta(OrderedModel.Meta):
169
+ class Meta:
536
170
  verbose_name = "Trade"
537
171
  verbose_name_plural = "Trades"
538
172
  indexes = [
539
173
  models.Index(fields=["underlying_instrument", "transaction_date"]),
540
174
  models.Index(fields=["portfolio", "underlying_instrument", "transaction_date"]),
541
- # models.Index(fields=["date", "underlying_instrument"]),
542
175
  ]
543
176
  constraints = [
544
177
  models.CheckConstraint(
@@ -557,38 +190,20 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
557
190
  ),
558
191
  name="internal_trade_set_only_for_subred",
559
192
  ),
560
- models.UniqueConstraint(
561
- fields=["portfolio", "transaction_date", "underlying_instrument"],
562
- name="unique_manual_trade",
563
- condition=Q(trade_proposal__isnull=False),
564
- ),
565
193
  ]
566
194
  # notification_email_template = "portfolio/email/trade_notification.html"
567
195
 
568
196
  def save(self, *args, **kwargs):
569
197
  if abs(self.weighting) < 10e-6:
570
198
  self.weighting = Decimal("0")
571
- if self.trade_proposal:
572
- if not self.underlying_instrument.is_investable_universe:
573
- self.underlying_instrument.is_investable_universe = True
574
- self.underlying_instrument.save()
575
- self.portfolio = self.trade_proposal.portfolio
576
- self.transaction_date = self.trade_proposal.trade_date
577
- self.value_date = self.trade_proposal.last_effective_date
578
199
  if not self.price:
579
200
  # we try to get the price if not provided directly from the underlying instrument
580
201
  self.price = self.get_price()
581
- if self.trade_proposal and not self.portfolio.only_weighting:
582
- estimated_shares = self.trade_proposal.get_estimated_shares(
583
- self.weighting, self.underlying_instrument, self.price
584
- )
585
- if estimated_shares:
586
- self.shares = estimated_shares
587
202
 
588
203
  if not self.custodian and self.bank:
589
204
  self.custodian = Custodian.get_by_mapping(self.bank)
590
205
 
591
- if self.transaction_subtype is None or self.trade_proposal:
206
+ if self.transaction_subtype is None:
592
207
  # if subtype not provided, we extract it automatically from the existing data.
593
208
  self._set_type()
594
209
  if self.id and hasattr(self, "claims"):
@@ -612,42 +227,12 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
612
227
  self.transaction_subtype = Trade.Type.REDEMPTION
613
228
  elif self.weighting is not None:
614
229
  if self.weighting > 0:
615
- if self._effective_weight:
616
- self.transaction_subtype = Trade.Type.INCREASE
617
- else:
618
- self.transaction_subtype = Trade.Type.BUY
230
+ self.transaction_subtype = Trade.Type.INCREASE
619
231
  elif self.weighting < 0:
620
- if self._target_weight:
621
- self.transaction_subtype = Trade.Type.DECREASE
622
- else:
623
- self.transaction_subtype = Trade.Type.SELL
232
+ self.transaction_subtype = Trade.Type.DECREASE
624
233
  else:
625
234
  self.transaction_subtype = Trade.Type.REBALANCE
626
235
 
627
- def get_type(self) -> str:
628
- """
629
- Return the expected transaction subtype based n
630
-
631
- """
632
-
633
- def get_asset(self) -> AssetPosition:
634
- asset = AssetPosition(
635
- underlying_quote=self.underlying_instrument,
636
- portfolio_created=None,
637
- portfolio=self.portfolio,
638
- date=self.transaction_date,
639
- initial_currency_fx_rate=self.currency_fx_rate,
640
- weighting=self._target_weight,
641
- initial_price=self.price,
642
- initial_shares=None,
643
- asset_valuation_date=self.transaction_date,
644
- currency=self.currency,
645
- is_estimated=False,
646
- )
647
- asset.set_weighting(self._target_weight)
648
- asset.pre_save()
649
- return asset
650
-
651
236
  def get_price(self) -> Decimal:
652
237
  try:
653
238
  return self.underlying_instrument.get_price(self.transaction_date)
@@ -662,31 +247,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
662
247
  ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
663
248
  return f"{ticker}{self.shares} ({self.bank})"
664
249
 
665
- def _build_dto(self, drift_factor: Decimal = None) -> TradeDTO:
666
- """
667
- Data Transfer Object
668
- Returns:
669
- DTO trade object
670
-
671
- """
672
- if not drift_factor:
673
- drift_factor = self.drift_factor
674
- return TradeDTO(
675
- id=self.id,
676
- underlying_instrument=self.underlying_instrument.id,
677
- previous_weight=self._previous_weight,
678
- target_weight=self._previous_weight * drift_factor + self.weighting,
679
- effective_shares=self._effective_shares,
680
- target_shares=self._target_shares,
681
- drift_factor=drift_factor,
682
- currency_fx_rate=self.currency_fx_rate,
683
- price=self.price,
684
- instrument_type=self.underlying_instrument.security_instrument_type.id,
685
- currency=self.underlying_instrument.currency,
686
- date=self.transaction_date,
687
- is_cash=self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent,
688
- )
689
-
690
250
  def get_alternative_valid_trades(self, share_delta: float = 0):
691
251
  return Trade.objects.filter(
692
252
  Q(underlying_instrument=self.underlying_instrument)
@@ -2,6 +2,8 @@ import numpy as np
2
2
  import pandas as pd
3
3
  from skfolio import Portfolio as BasePortfolio
4
4
 
5
+ from .utils import fix_quantization_error
6
+
5
7
 
6
8
  class Portfolio(BasePortfolio):
7
9
  @property
@@ -16,6 +18,12 @@ class Portfolio(BasePortfolio):
16
18
  )
17
19
  return df
18
20
 
21
+ def get_contributions(self) -> tuple[pd.Series, float]:
22
+ returns = self.X.iloc[-1, :].T
23
+ weights = self.all_weights_per_observation.iloc[-1, :].T
24
+ portfolio_returns = (weights * (returns + 1.0)).sum()
25
+ return returns, portfolio_returns
26
+
19
27
  def get_next_weights(self, round_precision: int = 8) -> dict[int, float]:
20
28
  """
21
29
  Given the next returns, compute the drifted weights of this portfolio
@@ -23,17 +31,13 @@ class Portfolio(BasePortfolio):
23
31
  Returns:
24
32
  A dictionary of weights (instrument ids as keys and weights as values)
25
33
  """
26
- returns = self.X.iloc[-1, :].T
34
+ returns, portfolio_returns = self.get_contributions()
27
35
  weights = self.all_weights_per_observation.iloc[-1, :].T
28
- portfolio_returns = (weights * (returns + 1.0)).sum()
29
36
  next_weights = weights * (returns + 1.0) / portfolio_returns
30
37
  next_weights = next_weights.dropna()
31
38
  next_weights = next_weights / next_weights.sum()
32
39
  if round_precision and not next_weights.empty:
33
- next_weights = next_weights.round(round_precision)
34
- quantization_error = 1.0 - next_weights.sum()
35
- largest_weight = next_weights.idxmax()
36
- next_weights.loc[largest_weight] = next_weights.loc[largest_weight] + quantization_error
40
+ next_weights = fix_quantization_error(next_weights, round_precision)
37
41
  return {i: round(w, round_precision) for i, w in next_weights.items()} # handle float precision manually
38
42
 
39
43
  def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
@@ -0,0 +1,9 @@
1
+ import pandas as pd
2
+
3
+
4
+ def fix_quantization_error(df: pd.Series, round_precision: int):
5
+ df = df.round(round_precision)
6
+ quantization_error = 1.0 - df.sum()
7
+ largest_weight = df.idxmax()
8
+ df.loc[largest_weight] = df.loc[largest_weight] + quantization_error
9
+ return df
@@ -117,7 +117,7 @@ class TradingService:
117
117
  if self._effective_portfolio:
118
118
  for trade in validated_trades:
119
119
  if (
120
- trade.effective_weight
120
+ trade.previous_weight
121
121
  and trade.underlying_instrument not in self._effective_portfolio.positions_map
122
122
  ):
123
123
  raise ValidationError("All effective position needs to be matched with a validated trade")
@@ -137,6 +137,7 @@ class TradingService:
137
137
 
138
138
  Returns: The normalized trades batch
139
139
  """
140
+
140
141
  instruments = effective_portfolio.positions_map.copy()
141
142
  instruments.update(target_portfolio.positions_map)
142
143
 
@@ -145,11 +146,11 @@ class TradingService:
145
146
  if not pos.is_cash:
146
147
  previous_weight = target_weight = 0
147
148
  effective_shares = target_shares = 0
148
- drift_factor = 1.0
149
+ daily_return = 0
149
150
  if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
150
151
  previous_weight = effective_pos.weighting
151
152
  effective_shares = effective_pos.shares
152
- drift_factor = effective_pos.drift_factor
153
+ daily_return = effective_pos.daily_return
153
154
  if target_pos := target_portfolio.positions_map.get(instrument_id, None):
154
155
  target_weight = target_pos.weighting
155
156
  if target_pos.shares is not None:
@@ -165,7 +166,8 @@ class TradingService:
165
166
  currency=pos.currency,
166
167
  price=Decimal(pos.price) if pos.price is not None else Decimal("0"),
167
168
  currency_fx_rate=Decimal(pos.currency_fx_rate),
168
- drift_factor=Decimal(drift_factor),
169
+ daily_return=Decimal(daily_return),
170
+ portfolio_contribution=effective_portfolio.portfolio_contribution,
169
171
  )
170
172
  trades.append(trade)
171
173
  return TradeBatch(trades)