wbportfolio 1.52.0__py2.py3-none-any.whl → 1.52.2__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 +1 -1
  2. wbportfolio/admin/transactions/__init__.py +0 -1
  3. wbportfolio/admin/transactions/dividends.py +40 -4
  4. wbportfolio/admin/transactions/fees.py +24 -14
  5. wbportfolio/admin/transactions/trades.py +34 -12
  6. wbportfolio/defaults/fees/default.py +7 -15
  7. wbportfolio/factories/__init__.py +0 -1
  8. wbportfolio/factories/dividends.py +8 -3
  9. wbportfolio/factories/fees.py +8 -4
  10. wbportfolio/factories/trades.py +10 -3
  11. wbportfolio/filters/transactions/__init__.py +1 -2
  12. wbportfolio/filters/transactions/fees.py +5 -10
  13. wbportfolio/filters/transactions/trades.py +17 -8
  14. wbportfolio/filters/transactions/utils.py +42 -0
  15. wbportfolio/import_export/handlers/dividend.py +7 -7
  16. wbportfolio/import_export/handlers/fees.py +11 -21
  17. wbportfolio/import_export/handlers/trade.py +5 -7
  18. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  19. wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
  20. wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
  21. wbportfolio/import_export/parsers/leonteq/trade.py +0 -5
  22. wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
  23. wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
  24. wbportfolio/import_export/parsers/natixis/fees.py +7 -9
  25. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
  26. wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
  27. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
  28. wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
  29. wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
  30. wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
  31. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
  32. wbportfolio/import_export/parsers/vontobel/management_fees.py +7 -7
  33. wbportfolio/import_export/parsers/vontobel/performance_fees.py +3 -3
  34. wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
  35. wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
  36. wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
  37. wbportfolio/models/mixins/liquidity_stress_test.py +3 -3
  38. wbportfolio/models/transactions/__init__.py +0 -2
  39. wbportfolio/models/transactions/claim.py +1 -1
  40. wbportfolio/models/transactions/dividends.py +41 -5
  41. wbportfolio/models/transactions/fees.py +55 -22
  42. wbportfolio/models/transactions/trade_proposals.py +26 -6
  43. wbportfolio/models/transactions/trades.py +111 -50
  44. wbportfolio/models/transactions/transactions.py +60 -156
  45. wbportfolio/serializers/signals.py +15 -10
  46. wbportfolio/serializers/transactions/__init__.py +0 -5
  47. wbportfolio/serializers/transactions/dividends.py +37 -9
  48. wbportfolio/serializers/transactions/fees.py +39 -10
  49. wbportfolio/serializers/transactions/trades.py +56 -16
  50. wbportfolio/tasks.py +2 -2
  51. wbportfolio/tests/conftest.py +2 -8
  52. wbportfolio/tests/models/test_imports.py +2 -7
  53. wbportfolio/tests/models/transactions/test_fees.py +7 -13
  54. wbportfolio/tests/models/transactions/test_trade_proposals.py +4 -2
  55. wbportfolio/urls.py +3 -6
  56. wbportfolio/viewsets/configs/buttons/__init__.py +1 -0
  57. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  58. wbportfolio/viewsets/configs/buttons/trades.py +8 -0
  59. wbportfolio/viewsets/configs/display/__init__.py +2 -3
  60. wbportfolio/viewsets/configs/display/fees.py +3 -3
  61. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -4
  62. wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
  63. wbportfolio/viewsets/configs/menu/__init__.py +0 -1
  64. wbportfolio/viewsets/configs/titles/__init__.py +2 -3
  65. wbportfolio/viewsets/configs/titles/fees.py +4 -8
  66. wbportfolio/viewsets/mixins.py +5 -1
  67. wbportfolio/viewsets/products.py +6 -6
  68. wbportfolio/viewsets/transactions/__init__.py +2 -7
  69. wbportfolio/viewsets/transactions/fees.py +22 -22
  70. wbportfolio/viewsets/transactions/trade_proposals.py +1 -0
  71. wbportfolio/viewsets/transactions/trades.py +2 -0
  72. {wbportfolio-1.52.0.dist-info → wbportfolio-1.52.2.dist-info}/METADATA +1 -1
  73. {wbportfolio-1.52.0.dist-info → wbportfolio-1.52.2.dist-info}/RECORD +75 -84
  74. wbportfolio/admin/transactions/transactions.py +0 -38
  75. wbportfolio/factories/transactions.py +0 -22
  76. wbportfolio/filters/transactions/transactions.py +0 -99
  77. wbportfolio/models/transactions/expiry.py +0 -7
  78. wbportfolio/serializers/transactions/expiry.py +0 -18
  79. wbportfolio/serializers/transactions/transactions.py +0 -85
  80. wbportfolio/viewsets/configs/display/transactions.py +0 -55
  81. wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
  82. wbportfolio/viewsets/configs/menu/transactions.py +0 -9
  83. wbportfolio/viewsets/configs/titles/transactions.py +0 -9
  84. wbportfolio/viewsets/transactions/transactions.py +0 -122
  85. {wbportfolio-1.52.0.dist-info → wbportfolio-1.52.2.dist-info}/WHEEL +0 -0
  86. {wbportfolio-1.52.0.dist-info → wbportfolio-1.52.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,15 +1,51 @@
1
- from decimal import Decimal
2
-
3
1
  from django.db import models
2
+ from wbcore.contrib.io.mixins import ImportMixin
4
3
 
5
4
  from wbportfolio.import_export.handlers.dividend import DividendImportHandler
6
5
 
7
- from .transactions import ShareMixin, Transaction
6
+ from .transactions import TransactionMixin
8
7
 
9
8
 
10
- class DividendTransaction(Transaction, ShareMixin, models.Model):
9
+ class DividendTransaction(TransactionMixin, ImportMixin, models.Model):
11
10
  import_export_handler_class = DividendImportHandler
11
+
12
+ class DistributionMethod(models.TextChoices):
13
+ PAYMENT = "Payment", "Payment"
14
+ REINVESTMENT = "Reinvestment", "Reinvestment"
15
+
16
+ ex_date = models.DateField(
17
+ verbose_name="Ex-Dividend Date",
18
+ help_text="The date on which the stock starts trading without the dividend",
19
+ )
20
+ record_date = models.DateField(
21
+ verbose_name="Record Date",
22
+ help_text="The date on which the holder must own the shares to be eligible for the dividend",
23
+ )
24
+ distribution_method = models.CharField(
25
+ max_length=255, verbose_name="Type", choices=DistributionMethod.choices, default=DistributionMethod.PAYMENT
26
+ )
12
27
  retrocession = models.FloatField(default=1)
28
+ price = models.DecimalField(
29
+ max_digits=15,
30
+ decimal_places=4,
31
+ help_text="The amount paid per share",
32
+ verbose_name="DPS",
33
+ )
34
+ total_value_gross = models.GeneratedField(
35
+ expression=models.F("price") * models.F("shares") * models.F("retrocession"),
36
+ output_field=models.DecimalField(
37
+ max_digits=20,
38
+ decimal_places=4,
39
+ ),
40
+ db_persist=True,
41
+ )
13
42
 
14
43
  def save(self, *args, **kwargs):
15
- super().save(*args, factor=Decimal(self.retrocession), **kwargs)
44
+ if not self.record_date and self.ex_date:
45
+ self.record_date = self.ex_date
46
+ elif self.record_date and not self.ex_date:
47
+ self.ex_date = self.record_date
48
+ super().save(*args, **kwargs)
49
+
50
+ def __str__(self):
51
+ return f"{self.total_value} - {self.value_date:%d.%m.%Y} : {str(self.underlying_instrument)} (in {str(self.portfolio)})"
@@ -1,17 +1,17 @@
1
1
  import importlib
2
2
  from contextlib import suppress
3
+ from decimal import Decimal
3
4
 
4
5
  from celery import shared_task
5
6
  from django.db import models
6
7
  from django.db.models import Exists, OuterRef, Q, QuerySet
7
8
  from django.dispatch import receiver
9
+ from wbcore.contrib.io.mixins import ImportMixin
8
10
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
9
11
 
10
12
  from wbportfolio.import_export.handlers.fees import FeesImportHandler
11
13
  from wbportfolio.models.products import Product
12
14
 
13
- from .transactions import Transaction
14
-
15
15
 
16
16
  class ValidFeesQueryset(QuerySet):
17
17
  def filter_only_valid_fees(self) -> QuerySet:
@@ -22,7 +22,7 @@ class ValidFeesQueryset(QuerySet):
22
22
  real_fees_exists=Exists(
23
23
  self.filter(
24
24
  transaction_subtype=OuterRef("transaction_subtype"),
25
- linked_product=OuterRef("linked_product"),
25
+ product=OuterRef("product"),
26
26
  fee_date=OuterRef("fee_date"),
27
27
  calculated=False,
28
28
  )
@@ -43,7 +43,7 @@ class ValidFeesManager(DefaultFeesManager):
43
43
  return super().get_queryset().filter_only_valid_fees()
44
44
 
45
45
 
46
- class Fees(Transaction):
46
+ class Fees(ImportMixin, models.Model):
47
47
  import_export_handler_class = FeesImportHandler
48
48
 
49
49
  class Type(models.TextChoices):
@@ -57,31 +57,61 @@ class Fees(Transaction):
57
57
  transaction_subtype = models.CharField(
58
58
  max_length=255, verbose_name="Fees Type", choices=Type.choices, default=Type.MANAGEMENT
59
59
  )
60
-
61
- fee_date = models.DateField() # needed for indexing
60
+ fee_date = models.DateField(
61
+ verbose_name="Fees Date",
62
+ help_text="The date that this fee was paid.",
63
+ ) # needed for indexing
64
+ product = models.ForeignKey(
65
+ "wbportfolio.Product",
66
+ related_name="fees",
67
+ on_delete=models.PROTECT,
68
+ verbose_name="Product",
69
+ )
70
+ currency = models.ForeignKey(
71
+ "currency.Currency",
72
+ related_name="fees",
73
+ on_delete=models.PROTECT,
74
+ verbose_name="Currency",
75
+ )
76
+ currency_fx_rate = models.DecimalField(
77
+ max_digits=14, decimal_places=8, default=Decimal(1.0), verbose_name="FOREX rate"
78
+ )
79
+ total_value = models.DecimalField(max_digits=20, decimal_places=4, verbose_name="Total Value")
80
+ total_value_gross = models.DecimalField(max_digits=20, decimal_places=4, verbose_name="Total Value Gross")
81
+ total_value_fx_portfolio = models.GeneratedField(
82
+ expression=models.F("currency_fx_rate") * models.F("total_value"),
83
+ output_field=models.DecimalField(
84
+ max_digits=20,
85
+ decimal_places=4,
86
+ ),
87
+ db_persist=True,
88
+ )
89
+ total_value_gross_fx_portfolio = models.GeneratedField(
90
+ expression=models.F("currency_fx_rate") * models.F("total_value_gross"),
91
+ output_field=models.DecimalField(
92
+ max_digits=20,
93
+ decimal_places=4,
94
+ ),
95
+ db_persist=True,
96
+ )
62
97
  calculated = models.BooleanField(
63
98
  default=True,
64
99
  help_text="A marker whether the fees were calculated or supplied.",
65
100
  verbose_name="Is calculated",
66
101
  )
67
-
68
- linked_product = models.ForeignKey(
69
- "wbportfolio.Product",
70
- related_name="transactionfees",
71
- on_delete=models.PROTECT,
72
- verbose_name="Product",
73
- )
102
+ created = models.DateTimeField(auto_now_add=True)
103
+ updated = models.DateTimeField(auto_now=True)
74
104
 
75
105
  class Meta:
76
106
  verbose_name = "Fees"
77
107
  verbose_name_plural = "Fees"
78
108
  indexes = [
79
- models.Index(fields=["linked_product"]),
80
- models.Index(fields=["transaction_subtype", "linked_product", "fee_date", "calculated"]),
109
+ models.Index(fields=["product"]),
110
+ models.Index(fields=["transaction_subtype", "product", "fee_date", "calculated"]),
81
111
  ]
82
112
  constraints = [
83
113
  models.UniqueConstraint(
84
- fields=["linked_product", "fee_date", "transaction_subtype", "calculated"], name="unique_fees"
114
+ fields=["product", "fee_date", "transaction_subtype", "calculated"], name="unique_fees"
85
115
  ),
86
116
  ]
87
117
 
@@ -89,11 +119,14 @@ class Fees(Transaction):
89
119
  valid_objects = ValidFeesManager()
90
120
 
91
121
  def save(self, *args, **kwargs):
92
- self.fee_date = self.transaction_date
122
+ if self.total_value_gross is None and self.total_value is not None:
123
+ self.total_value_gross = self.total_value
124
+ elif self.total_value is None and self.total_value_gross is not None:
125
+ self.total_value = self.total_value_gross
93
126
  super().save(*args, **kwargs)
94
127
 
95
128
  def __str__(self):
96
- return f"{self.transaction_date:%d.%m.%Y} - {self.Type[self.transaction_subtype]}: {self.portfolio.name}"
129
+ return f"{self.fee_date:%d.%m.%Y} - {self.Type[self.transaction_subtype]}: {self.product.name}"
97
130
 
98
131
  @classmethod
99
132
  def get_endpoint_basename(cls):
@@ -111,8 +144,8 @@ class FeeCalculation(models.Model):
111
144
  calculation_module = importlib.import_module(import_path)
112
145
  for new_fees in calculation_module.fees_calculation(price.id):
113
146
  Fees.objects.update_or_create(
114
- linked_product=new_fees.pop("linked_product"),
115
- transaction_date=new_fees.pop("transaction_date"),
147
+ product=new_fees.pop("product"),
148
+ fee_date=new_fees.pop("fee_date"),
116
149
  transaction_subtype=new_fees.pop("transaction_subtype"),
117
150
  calculated=True,
118
151
  defaults=new_fees,
@@ -145,10 +178,10 @@ def update_or_create_fees_post(sender, instance, created, raw, **kwargs):
145
178
  # .filter(
146
179
  # transaction_date=instance.transaction_date,
147
180
  # transaction_subtype=instance.transaction_subtype,
148
- # linked_product=instance.linked_product,
181
+ # product=instance.product,
149
182
  # )
150
183
  # .exists()
151
184
  # ):
152
185
  # raise ValueError(
153
- # f"A fees object already exists with date, type and product = {instance.transaction_date}, {instance.transaction_subtype}, {instance.linked_product}"
186
+ # f"A fees object already exists with date, type and product = {instance.transaction_date}, {instance.type}, {instance.product}"
154
187
  # )
@@ -5,6 +5,7 @@ from decimal import Decimal
5
5
  from typing import TypeVar
6
6
 
7
7
  from celery import shared_task
8
+ from django.contrib.messages import error, info
8
9
  from django.core.exceptions import ValidationError
9
10
  from django.db import models
10
11
  from django.utils.functional import cached_property
@@ -243,7 +244,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
243
244
  effective_portfolio = self.portfolio._build_dto(last_effective_date)
244
245
  if not target_portfolio:
245
246
  target_portfolio = self._get_default_target_portfolio()
246
-
247
247
  if target_portfolio:
248
248
  service = TradingService(
249
249
  self.trade_date,
@@ -251,7 +251,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
251
251
  target_portfolio=target_portfolio,
252
252
  )
253
253
  if validate_trade:
254
- service.normalize()
255
254
  service.is_valid()
256
255
  trades = service.validated_trades
257
256
  else:
@@ -450,12 +449,18 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
450
449
  def submit(self, by=None, description=None, **kwargs):
451
450
  self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
452
451
  trades = []
452
+ trades_validation_errors = []
453
453
  for trade in self.trades.all():
454
- trade.status = Trade.Status.SUBMIT
455
- trade.comment = ""
454
+ if not trade.last_underlying_quote_price:
455
+ trade.status = Trade.Status.FAILED
456
+ trades_validation_errors.append(
457
+ f"Trade failed because no price is found for {trade.underlying_instrument.computed_str} on {trade.transaction_date:%Y-%m-%d}"
458
+ )
459
+ else:
460
+ trade.status = Trade.Status.SUBMIT
456
461
  trades.append(trade)
457
462
 
458
- Trade.objects.bulk_update(trades, ["status", "comment"])
463
+ Trade.objects.bulk_update(trades, ["status"])
459
464
 
460
465
  # If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
461
466
  estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
@@ -470,9 +475,19 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
470
475
  estimated_cash_position._build_dto()
471
476
  )
472
477
  target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
473
-
474
478
  self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
475
479
 
480
+ if (
481
+ trades_validation_errors
482
+ and (fsm_context := getattr(self, "fsm_context", None))
483
+ and (request := fsm_context.get("request", None))
484
+ ):
485
+ msg = f"""The reason for the failed trades are: <ul>
486
+ {''.join(map(lambda o: '<li>' + o + '</li>', trades_validation_errors))}
487
+ </ul>
488
+ """
489
+ error(request, msg, extra_tags="auto_close=0")
490
+
476
491
  def can_submit(self):
477
492
  errors = dict()
478
493
  errors_list = []
@@ -543,6 +558,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
543
558
  if estimated_cash_position.weighting and len(trades) > 0:
544
559
  estimated_cash_position.pre_save()
545
560
  assets.append(estimated_cash_position)
561
+ if (fsm_context := getattr(self, "fsm_context", None)) and (request := fsm_context.get("request", None)):
562
+ info(
563
+ request,
564
+ f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}",
565
+ )
546
566
 
547
567
  Trade.objects.bulk_update(trades, ["status"])
548
568
  self.portfolio.bulk_create_positions(
@@ -24,11 +24,14 @@ from django.utils.translation import gettext_lazy as _
24
24
  from django_fsm import GET_STATE, FSMField, transition
25
25
  from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet
26
26
  from wbcore.contrib.icons import WBIcon
27
+ from wbcore.contrib.io.mixins import ImportMixin
27
28
  from wbcore.enums import RequestType
28
29
  from wbcore.metadata.configs.buttons import ActionButton
29
- from wbcore.models import WBModel
30
+ from wbcore.signals import pre_merge
30
31
  from wbcore.signals.models import pre_collection
32
+ from wbfdm.models import Instrument
31
33
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
34
+ from wbfdm.signals import add_instrument_to_investable_universe
32
35
 
33
36
  from wbportfolio.import_export.handlers.trade import TradeImportHandler
34
37
  from wbportfolio.models.asset import AssetPosition
@@ -36,7 +39,7 @@ from wbportfolio.models.custodians import Custodian
36
39
  from wbportfolio.models.roles import PortfolioRole
37
40
  from wbportfolio.pms.typing import Trade as TradeDTO
38
41
 
39
- from .transactions import ShareMixin, Transaction
42
+ from .transactions import TransactionMixin
40
43
 
41
44
 
42
45
  class TradeQueryset(OrderedModelQuerySet):
@@ -117,7 +120,7 @@ class ValidCustomerTradeManager(DefaultTradeManager):
117
120
  return qs
118
121
 
119
122
 
120
- class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
123
+ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
121
124
  import_export_handler_class = TradeImportHandler
122
125
 
123
126
  TRADE_WINDOW_INTERVAL = 7
@@ -139,18 +142,26 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
139
142
  SELL = "SELL", "Sell"
140
143
  NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
141
144
 
142
- external_identifier2 = models.CharField(
143
- max_length=255,
144
- null=True,
145
- blank=True,
146
- help_text="A second external identifier that was supplied.",
147
- verbose_name="External Identifier 2",
148
- )
149
-
150
145
  transaction_subtype = models.CharField(
151
146
  max_length=32, default=Type.BUY, choices=Type.choices, verbose_name="Trade Type"
152
147
  )
153
148
  status = FSMField(default=Status.CONFIRMED, choices=Status.choices, verbose_name="Status")
149
+ transaction_date = models.DateField(
150
+ verbose_name="Trade Date",
151
+ help_text="The date that this transaction was traded.",
152
+ )
153
+ book_date = models.DateField(
154
+ verbose_name="Trade Date",
155
+ help_text="The date that this transaction was booked.",
156
+ )
157
+ shares = models.DecimalField(
158
+ max_digits=15,
159
+ decimal_places=4,
160
+ default=Decimal("0.0"),
161
+ help_text="The number of shares that were traded.",
162
+ verbose_name="Shares",
163
+ )
164
+
154
165
  weighting = models.DecimalField(
155
166
  max_digits=16,
156
167
  decimal_places=6,
@@ -158,36 +169,45 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
158
169
  help_text="The weight to be multiplied against the target",
159
170
  verbose_name="Weight",
160
171
  )
161
- bank = models.CharField(
162
- max_length=255,
163
- help_text="The bank/counterparty/custodian the trade went through.",
164
- verbose_name="Counterparty",
172
+ claimed_shares = models.DecimalField(
173
+ max_digits=15,
174
+ decimal_places=4,
175
+ default=Decimal(0),
176
+ help_text="The number of shares that were claimed.",
177
+ verbose_name="Claimed Shares",
165
178
  )
166
- custodian = models.ForeignKey(
167
- "wbportfolio.Custodian", null=True, blank=True, on_delete=models.SET_NULL, related_name="trades"
179
+ diff_shares = models.GeneratedField(
180
+ expression=F("shares") - F("claimed_shares"),
181
+ output_field=models.DecimalField(max_digits=15, decimal_places=4),
182
+ db_persist=True,
183
+ )
184
+ internal_trade = models.OneToOneField(
185
+ "wbportfolio.Trade",
186
+ null=True,
187
+ blank=True,
188
+ on_delete=models.SET_NULL,
189
+ related_name="internal_subscription_redemption_trade",
168
190
  )
169
191
  marked_for_deletion = models.BooleanField(
170
192
  default=False,
171
193
  help_text="If this is checked, then the trade is supposed to be deleted.",
172
194
  verbose_name="To be deleted",
173
195
  )
174
-
175
- # Only valid for subscription and redemption trade
176
196
  marked_as_internal = models.BooleanField(
177
197
  default=False,
178
198
  help_text="If this is checked, then this subscription or redemption is considered internal and will not be considered in any AUM computation",
179
199
  verbose_name="Internal",
180
200
  )
181
- internal_trade = models.OneToOneField(
182
- "wbportfolio.Trade",
183
- null=True,
184
- blank=True,
185
- on_delete=models.SET_NULL,
186
- related_name="internal_subscription_redemption_trade",
187
- )
188
-
189
201
  pending = models.BooleanField(default=False)
190
202
  exclude_from_history = models.BooleanField(default=False)
203
+ bank = models.CharField(
204
+ max_length=255,
205
+ help_text="The bank/counterparty/custodian the trade went through.",
206
+ verbose_name="Counterparty",
207
+ )
208
+ custodian = models.ForeignKey(
209
+ "wbportfolio.Custodian", null=True, blank=True, on_delete=models.SET_NULL, related_name="trades"
210
+ )
191
211
  register = models.ForeignKey(
192
212
  to="wbportfolio.Register",
193
213
  null=True,
@@ -195,7 +215,6 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
195
215
  related_name="trades",
196
216
  on_delete=models.PROTECT,
197
217
  )
198
-
199
218
  trade_proposal = models.ForeignKey(
200
219
  to="wbportfolio.TradeProposal",
201
220
  null=True,
@@ -204,18 +223,23 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
204
223
  on_delete=models.CASCADE,
205
224
  help_text="The Trade Proposal this trade is coming from",
206
225
  )
207
- claimed_shares = models.DecimalField(
208
- max_digits=15,
209
- decimal_places=4,
210
- default=Decimal(0),
211
- help_text="The number of shares that were claimed.",
212
- verbose_name="Claimed Shares",
226
+
227
+ external_id = models.CharField(
228
+ max_length=255,
229
+ null=True,
230
+ blank=True,
231
+ help_text="An external identifier that was supplied.",
232
+ verbose_name="External Identifier",
213
233
  )
214
- diff_shares = models.GeneratedField(
215
- expression=F("shares") - F("claimed_shares"),
216
- output_field=models.DecimalField(max_digits=15, decimal_places=4),
217
- db_persist=True,
234
+ external_id_alternative = models.CharField(
235
+ max_length=255,
236
+ null=True,
237
+ blank=True,
238
+ help_text="A second external identifier that was supplied.",
239
+ verbose_name="Alternative External Identifier",
218
240
  )
241
+
242
+ # Manager
219
243
  objects = DefaultTradeManager()
220
244
  annotated_objects = DefaultTradeManager(with_annotation=True)
221
245
  valid_customer_trade_objects = ValidCustomerTradeManager()
@@ -247,8 +271,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
247
271
  on_error="FAILED",
248
272
  )
249
273
  def submit(self, by=None, description=None, **kwargs):
250
- if not self.last_underlying_quote_price:
251
- self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
274
+ pass
252
275
 
253
276
  def can_submit(self):
254
277
  pass
@@ -262,7 +285,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
262
285
  ),
263
286
  )
264
287
  def fail(self, **kwargs):
265
- self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
288
+ pass
266
289
 
267
290
  @cached_property
268
291
  def last_underlying_quote_price(self) -> InstrumentPrice | None:
@@ -470,6 +493,11 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
470
493
  class Meta(OrderedModel.Meta):
471
494
  verbose_name = "Trade"
472
495
  verbose_name_plural = "Trades"
496
+ indexes = [
497
+ models.Index(fields=["underlying_instrument", "transaction_date"]),
498
+ models.Index(fields=["portfolio", "underlying_instrument", "transaction_date"]),
499
+ # models.Index(fields=["date", "underlying_instrument"]),
500
+ ]
473
501
  constraints = [
474
502
  models.CheckConstraint(
475
503
  check=models.Q(marked_as_internal=False)
@@ -487,6 +515,11 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
487
515
  ),
488
516
  name="internal_trade_set_only_for_subred",
489
517
  ),
518
+ models.UniqueConstraint(
519
+ fields=["portfolio", "transaction_date", "underlying_instrument"],
520
+ name="unique_manual_trade",
521
+ condition=Q(trade_proposal__isnull=False),
522
+ ),
490
523
  ]
491
524
  # notification_email_template = "portfolio/email/trade_notification.html"
492
525
 
@@ -494,9 +527,13 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
494
527
  super().__init__(*args, **kwargs)
495
528
  if target_weight is not None: # if target weight is provided, we guess the corresponding weighting
496
529
  self.weighting = Decimal(target_weight) - self._effective_weight
497
- self._set_transaction_subtype()
530
+ self._set_type()
498
531
 
499
532
  def save(self, *args, **kwargs):
533
+ if not self.value_date:
534
+ self.value_date = self.transaction_date
535
+ if not self.book_date:
536
+ self.book_date = self.transaction_date
500
537
  if self.trade_proposal:
501
538
  self.portfolio = self.trade_proposal.portfolio
502
539
  self.transaction_date = self.trade_proposal.trade_date
@@ -512,20 +549,16 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
512
549
  with suppress(Exception):
513
550
  self.price = self.underlying_instrument.get_price(self.value_date)
514
551
 
515
- self.transaction_type = Transaction.Type.TRADE
516
-
517
552
  if self.transaction_subtype is None or self.trade_proposal:
518
553
  # if subtype not provided, we extract it automatically from the existing data.
519
- self._set_transaction_subtype()
554
+ self._set_type()
520
555
  if self.id and hasattr(self, "claims"):
521
- self.claimed_shares = self.trade.claims.filter(status="APPROVED").aggregate(s=Sum("shares"))[
522
- "s"
523
- ] or Decimal(0)
556
+ self.claimed_shares = self.claims.filter(status="APPROVED").aggregate(s=Sum("shares"))["s"] or Decimal(0)
524
557
  if self.internal_trade:
525
558
  self.marked_as_internal = True
526
559
  super().save(*args, **kwargs)
527
560
 
528
- def _set_transaction_subtype(self):
561
+ def _set_type(self):
529
562
  if self.weighting == 0:
530
563
  self.transaction_subtype = Trade.Type.NO_CHANGE
531
564
  if self.underlying_instrument.instrument_type.key == "product":
@@ -548,7 +581,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
548
581
  else:
549
582
  self.transaction_subtype = Trade.Type.REBALANCE
550
583
 
551
- def get_transaction_subtype(self) -> str:
584
+ def get_type(self) -> str:
552
585
  """
553
586
  Return the expected transaction subtype based n
554
587
 
@@ -742,6 +775,10 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
742
775
  def get_representation_label_key(cls):
743
776
  return "{{|:-}}{{transaction_date}}{{|::}}{{bank}}{{|-:}} {{claimed_shares}} / {{shares}} (∆ {{diff_shares}})"
744
777
 
778
+ @classmethod
779
+ def get_representation_value_key(cls):
780
+ return "id"
781
+
745
782
 
746
783
  @shared_task
747
784
  def align_custodian():
@@ -756,3 +793,27 @@ def align_custodian():
756
793
  def compute_claimed_shares_on_claim_save(sender, instance, created, raw, **kwargs):
757
794
  if not raw and instance.trade:
758
795
  instance.trade.save()
796
+
797
+
798
+ @receiver(pre_merge, sender="wbfdm.Instrument")
799
+ def pre_merge_instrument(sender: models.Model, merged_object: "Instrument", main_object: "Instrument", **kwargs):
800
+ """
801
+ Simply reassign the transactions linked to the merged instrument to the main instrument
802
+ """
803
+ merged_object.trades.update(underlying_instrument=main_object)
804
+
805
+
806
+ @receiver(add_instrument_to_investable_universe, sender="wbfdm.Instrument")
807
+ def add_instrument_to_investable_universe_from_transactions(sender: models.Model, **kwargs) -> list[int]:
808
+ """
809
+ register all instrument linked to assets as within the investible universe
810
+ """
811
+ return list(
812
+ (
813
+ Instrument.objects.annotate(
814
+ transaction_exists=models.Exists(Trade.objects.filter(underlying_instrument=models.OuterRef("pk")))
815
+ ).filter(transaction_exists=True)
816
+ )
817
+ .distinct()
818
+ .values_list("id", flat=True)
819
+ )