wbportfolio 1.52.1__py2.py3-none-any.whl → 1.52.2rc0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/admin/__init__.py +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -1
- wbportfolio/admin/transactions/dividends.py +40 -4
- wbportfolio/admin/transactions/fees.py +24 -14
- wbportfolio/admin/transactions/trades.py +34 -12
- wbportfolio/defaults/fees/default.py +7 -15
- wbportfolio/factories/__init__.py +0 -1
- wbportfolio/factories/dividends.py +8 -3
- wbportfolio/factories/fees.py +8 -4
- wbportfolio/factories/trades.py +10 -3
- wbportfolio/filters/transactions/__init__.py +1 -2
- wbportfolio/filters/transactions/fees.py +5 -10
- wbportfolio/filters/transactions/trades.py +17 -8
- wbportfolio/filters/transactions/utils.py +42 -0
- wbportfolio/import_export/handlers/dividend.py +7 -7
- wbportfolio/import_export/handlers/fees.py +11 -21
- wbportfolio/import_export/handlers/trade.py +5 -7
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
- wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
- wbportfolio/import_export/parsers/leonteq/trade.py +0 -5
- wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
- wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
- wbportfolio/import_export/parsers/natixis/fees.py +7 -9
- wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
- wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
- wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
- wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
- wbportfolio/import_export/parsers/vontobel/management_fees.py +7 -7
- wbportfolio/import_export/parsers/vontobel/performance_fees.py +3 -3
- wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
- wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
- wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
- wbportfolio/models/mixins/liquidity_stress_test.py +3 -3
- wbportfolio/models/transactions/__init__.py +0 -2
- wbportfolio/models/transactions/claim.py +1 -1
- wbportfolio/models/transactions/dividends.py +41 -5
- wbportfolio/models/transactions/fees.py +55 -22
- wbportfolio/models/transactions/trade_proposals.py +25 -3
- wbportfolio/models/transactions/trades.py +111 -50
- wbportfolio/models/transactions/transactions.py +60 -156
- wbportfolio/serializers/signals.py +15 -10
- wbportfolio/serializers/transactions/__init__.py +0 -5
- wbportfolio/serializers/transactions/dividends.py +37 -9
- wbportfolio/serializers/transactions/fees.py +39 -10
- wbportfolio/serializers/transactions/trades.py +56 -16
- wbportfolio/tasks.py +2 -2
- wbportfolio/tests/conftest.py +2 -8
- wbportfolio/tests/models/test_imports.py +2 -7
- wbportfolio/tests/models/transactions/test_fees.py +7 -13
- wbportfolio/tests/models/transactions/test_trade_proposals.py +4 -2
- wbportfolio/urls.py +3 -6
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/display/__init__.py +2 -3
- wbportfolio/viewsets/configs/display/fees.py +3 -3
- wbportfolio/viewsets/configs/endpoints/__init__.py +3 -4
- wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +0 -1
- wbportfolio/viewsets/configs/titles/__init__.py +2 -3
- wbportfolio/viewsets/configs/titles/fees.py +4 -8
- wbportfolio/viewsets/mixins.py +5 -1
- wbportfolio/viewsets/products.py +6 -6
- wbportfolio/viewsets/transactions/__init__.py +2 -7
- wbportfolio/viewsets/transactions/fees.py +22 -22
- {wbportfolio-1.52.1.dist-info → wbportfolio-1.52.2rc0.dist-info}/METADATA +1 -1
- {wbportfolio-1.52.1.dist-info → wbportfolio-1.52.2rc0.dist-info}/RECORD +71 -80
- wbportfolio/admin/transactions/transactions.py +0 -38
- wbportfolio/factories/transactions.py +0 -22
- wbportfolio/filters/transactions/transactions.py +0 -99
- wbportfolio/models/transactions/expiry.py +0 -7
- wbportfolio/serializers/transactions/expiry.py +0 -18
- wbportfolio/serializers/transactions/transactions.py +0 -85
- wbportfolio/viewsets/configs/display/transactions.py +0 -55
- wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
- wbportfolio/viewsets/configs/menu/transactions.py +0 -9
- wbportfolio/viewsets/configs/titles/transactions.py +0 -9
- wbportfolio/viewsets/transactions/transactions.py +0 -122
- {wbportfolio-1.52.1.dist-info → wbportfolio-1.52.2rc0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.52.1.dist-info → wbportfolio-1.52.2rc0.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
|
|
6
|
+
from .transactions import TransactionMixin
|
|
8
7
|
|
|
9
8
|
|
|
10
|
-
class DividendTransaction(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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=["
|
|
80
|
-
models.Index(fields=["transaction_subtype", "
|
|
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=["
|
|
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.
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
#
|
|
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.
|
|
186
|
+
# f"A fees object already exists with date, type and product = {instance.transaction_date}, {instance.type}, {instance.product}"
|
|
154
187
|
# )
|
|
@@ -5,7 +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 info
|
|
8
|
+
from django.contrib.messages import error, info
|
|
9
9
|
from django.core.exceptions import ValidationError
|
|
10
10
|
from django.db import models
|
|
11
11
|
from django.utils.functional import cached_property
|
|
@@ -265,7 +265,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
265
265
|
try:
|
|
266
266
|
trade = self.trades.get(underlying_instrument=instrument)
|
|
267
267
|
trade.weighting = weighting
|
|
268
|
-
trade.comment = ""
|
|
269
268
|
trade.currency_fx_rate = currency_fx_rate
|
|
270
269
|
trade.status = Trade.Status.DRAFT
|
|
271
270
|
except Trade.DoesNotExist:
|
|
@@ -449,7 +448,19 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
449
448
|
)
|
|
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
|
+
for trade in self.trades.all():
|
|
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
|
|
461
|
+
trades.append(trade)
|
|
462
|
+
|
|
463
|
+
Trade.objects.bulk_update(trades, ["status"])
|
|
453
464
|
|
|
454
465
|
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
455
466
|
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
@@ -466,6 +477,17 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
466
477
|
target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
|
|
467
478
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
468
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
|
+
|
|
469
491
|
def can_submit(self):
|
|
470
492
|
errors = dict()
|
|
471
493
|
errors_list = []
|
|
@@ -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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
"
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
554
|
+
self._set_type()
|
|
520
555
|
if self.id and hasattr(self, "claims"):
|
|
521
|
-
self.claimed_shares = self.
|
|
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
|
|
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
|
|
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
|
+
)
|