wbportfolio 1.48.0__py2.py3-none-any.whl → 1.49.1__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/factories/__init__.py +1 -3
- wbportfolio/factories/dividends.py +1 -0
- wbportfolio/factories/portfolios.py +0 -12
- wbportfolio/factories/product_groups.py +8 -1
- wbportfolio/factories/products.py +18 -0
- wbportfolio/factories/trades.py +5 -1
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +1 -1
- wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -1
- wbportfolio/import_export/handlers/trade.py +13 -2
- wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -1
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py +126 -0
- wbportfolio/models/portfolio.py +15 -16
- wbportfolio/models/transactions/claim.py +8 -7
- wbportfolio/models/transactions/dividends.py +3 -20
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +163 -63
- wbportfolio/models/transactions/trades.py +24 -22
- wbportfolio/models/transactions/transactions.py +37 -37
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tests/models/test_portfolios.py +2 -2
- wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
- wbportfolio/tests/models/transactions/test_trades.py +14 -0
- wbportfolio/tests/signals.py +1 -1
- wbportfolio/tests/viewsets/test_performances.py +2 -1
- wbportfolio/viewsets/configs/display/assets.py +0 -11
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/products.py +0 -13
- wbportfolio/viewsets/configs/display/trades.py +24 -17
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/configs/endpoints/transactions.py +6 -0
- wbportfolio/viewsets/portfolios.py +39 -8
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- wbportfolio/viewsets/transactions/trades.py +105 -13
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/METADATA +1 -1
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/RECORD +46 -43
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/WHEEL +0 -0
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,18 +9,16 @@ from .claim import (
|
|
|
9
9
|
from .custodians import CustodianFactory
|
|
10
10
|
from .dividends import DividendTransactionsFactory
|
|
11
11
|
from .fees import FeesFactory
|
|
12
|
-
from wbfdm.factories.instrument_prices import InstrumentPriceFactory
|
|
13
12
|
from .portfolios import (
|
|
14
13
|
InstrumentPortfolioThroughModelFactory,
|
|
15
14
|
ModelPortfolioFactory,
|
|
16
|
-
ModelPortfolioWithBaseProductFactory,
|
|
17
15
|
PortfolioFactory,
|
|
18
16
|
)
|
|
19
17
|
from .portfolio_swing_pricings import PortfolioSwingPricingFactory
|
|
20
18
|
from .portfolio_cash_targets import PortfolioCashTargetFactory
|
|
21
19
|
from .portfolio_cash_flow import DailyPortfolioCashFlowFactory
|
|
22
20
|
from .product_groups import ProductGroupFactory, ProductGroupRepresentantFactory
|
|
23
|
-
from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory
|
|
21
|
+
from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory, ModelPortfolioWithBaseProductFactory
|
|
24
22
|
from .reconciliations import AccountReconciliationFactory, AccountReconciliationLineFactory
|
|
25
23
|
from .roles import ManagerPortfolioRoleFactory, ProductPortfolioRoleFactory
|
|
26
24
|
from .trades import CustomerTradeFactory, TradeFactory, TradeProposalFactory
|
|
@@ -11,5 +11,6 @@ class DividendTransactionsFactory(TransactionFactory):
|
|
|
11
11
|
class Meta:
|
|
12
12
|
model = DividendTransaction
|
|
13
13
|
|
|
14
|
+
retrocession = 1.0
|
|
14
15
|
shares = factory.LazyAttribute(lambda o: random.randint(10, 10000))
|
|
15
16
|
price = factory.LazyAttribute(lambda o: random.randint(10, 10000))
|
|
@@ -9,8 +9,6 @@ from wbportfolio.models import (
|
|
|
9
9
|
PortfolioPortfolioThroughModel,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
from .products import ProductFactory
|
|
13
|
-
|
|
14
12
|
|
|
15
13
|
class PortfolioFactory(factory.django.DjangoModelFactory):
|
|
16
14
|
class Meta:
|
|
@@ -43,16 +41,6 @@ class ModelPortfolioFactory(PortfolioFactory):
|
|
|
43
41
|
)
|
|
44
42
|
|
|
45
43
|
|
|
46
|
-
class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
|
|
47
|
-
@factory.post_generation
|
|
48
|
-
def create_instrument(self, create, extracted, **kwargs):
|
|
49
|
-
if create:
|
|
50
|
-
instrument = ProductFactory.create()
|
|
51
|
-
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
52
|
-
instrument=instrument, defaults={"portfolio": self}
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
44
|
class InstrumentPortfolioThroughModelFactory(factory.django.DjangoModelFactory):
|
|
57
45
|
instrument = factory.SubFactory("wbportfolio.factories.ProductFactory")
|
|
58
46
|
portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import factory
|
|
2
2
|
from wbfdm.factories.instruments import InstrumentFactory
|
|
3
3
|
|
|
4
|
-
from wbportfolio.
|
|
4
|
+
from wbportfolio.factories import PortfolioFactory
|
|
5
|
+
from wbportfolio.models import InstrumentPortfolioThroughModel, ProductGroup, ProductGroupRepresentant
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class ProductGroupFactory(InstrumentFactory):
|
|
@@ -19,6 +20,12 @@ class ProductGroupFactory(InstrumentFactory):
|
|
|
19
20
|
auditor = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
|
|
20
21
|
paying_agent = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
|
|
21
22
|
|
|
23
|
+
@factory.post_generation
|
|
24
|
+
def create_initial_portfolio(product_group, *args, **kwargs):
|
|
25
|
+
if product_group.id and not product_group.portfolios.exists():
|
|
26
|
+
portfolio = PortfolioFactory.create()
|
|
27
|
+
InstrumentPortfolioThroughModel.objects.create(instrument=product_group, portfolio=portfolio)
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
class ProductGroupRepresentantFactory(factory.django.DjangoModelFactory):
|
|
24
31
|
class Meta:
|
|
@@ -4,6 +4,8 @@ import factory
|
|
|
4
4
|
from wbcore.contrib.directory.factories.entries import CompanyFactory
|
|
5
5
|
from wbfdm.factories.instruments import InstrumentFactory, InstrumentTypeFactory
|
|
6
6
|
|
|
7
|
+
from wbportfolio.factories import ModelPortfolioFactory, PortfolioFactory
|
|
8
|
+
from wbportfolio.models import InstrumentPortfolioThroughModel
|
|
7
9
|
from wbportfolio.models.products import (
|
|
8
10
|
AssetClass,
|
|
9
11
|
InvestmentIndex,
|
|
@@ -37,6 +39,12 @@ class ProductFactory(InstrumentFactory):
|
|
|
37
39
|
external_webpage = factory.Faker("url")
|
|
38
40
|
instrument_type = factory.LazyAttribute(lambda o: InstrumentTypeFactory.create(name="Product", key="product"))
|
|
39
41
|
|
|
42
|
+
@factory.post_generation
|
|
43
|
+
def create_initial_portfolio(product, *args, **kwargs):
|
|
44
|
+
if product.id and not product.portfolios.exists():
|
|
45
|
+
portfolio = PortfolioFactory.create()
|
|
46
|
+
InstrumentPortfolioThroughModel.objects.create(instrument=product, portfolio=portfolio)
|
|
47
|
+
|
|
40
48
|
# wbportfolio = factory.SubFactory(PortfolioFactory)
|
|
41
49
|
# portfolio_computed = factory.SubFactory(PortfolioFactory)
|
|
42
50
|
|
|
@@ -55,3 +63,13 @@ class WhiteLabelProductFactory(ProductFactory):
|
|
|
55
63
|
|
|
56
64
|
class IndexProductFactory(ProductFactory):
|
|
57
65
|
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
|
|
69
|
+
@factory.post_generation
|
|
70
|
+
def create_instrument(self, create, extracted, **kwargs):
|
|
71
|
+
if create:
|
|
72
|
+
instrument = ProductFactory.create()
|
|
73
|
+
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
74
|
+
instrument=instrument, defaults={"portfolio": self}
|
|
75
|
+
)
|
wbportfolio/factories/trades.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import random
|
|
2
2
|
|
|
3
3
|
import factory
|
|
4
|
+
from faker import Faker
|
|
5
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
4
6
|
|
|
5
7
|
from wbportfolio.models import Trade, TradeProposal
|
|
6
8
|
|
|
7
9
|
from .transactions import TransactionFactory
|
|
8
10
|
|
|
11
|
+
fake = Faker()
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class TradeFactory(TransactionFactory):
|
|
11
15
|
class Meta:
|
|
@@ -22,7 +26,7 @@ class TradeProposalFactory(factory.django.DjangoModelFactory):
|
|
|
22
26
|
class Meta:
|
|
23
27
|
model = TradeProposal
|
|
24
28
|
|
|
25
|
-
trade_date = factory.
|
|
29
|
+
trade_date = factory.LazyAttribute(lambda o: (fake.date_object() + BDay(1)).date())
|
|
26
30
|
comment = factory.Faker("paragraph")
|
|
27
31
|
portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
|
|
28
32
|
creator = factory.SubFactory("wbcore.contrib.directory.factories.PersonFactory")
|
|
@@ -23,7 +23,7 @@ class DividendImportHandler(ImportExportHandler):
|
|
|
23
23
|
data["value_date"] = datetime.strptime(data["value_date"], "%Y-%m-%d").date()
|
|
24
24
|
from wbportfolio.models import Portfolio
|
|
25
25
|
|
|
26
|
-
data["portfolio"] = Portfolio.
|
|
26
|
+
data["portfolio"] = Portfolio.all_objects.get(id=data["portfolio"])
|
|
27
27
|
instrument = self.instrument_handler.process_object(
|
|
28
28
|
data["underlying_instrument"], only_security=False, read_only=True
|
|
29
29
|
)[0]
|
|
@@ -26,7 +26,7 @@ class FeesImportHandler(ImportExportHandler):
|
|
|
26
26
|
|
|
27
27
|
data["linked_product"] = Product.objects.get(id=data["linked_product"])
|
|
28
28
|
if "porfolio" in data:
|
|
29
|
-
data["portfolio"] = Portfolio.
|
|
29
|
+
data["portfolio"] = Portfolio.all_objects.get(id=data["portfolio"])
|
|
30
30
|
else:
|
|
31
31
|
data["portfolio"] = data["linked_product"].primary_portfolio
|
|
32
32
|
data["underlying_instrument"] = Cash.objects.filter(currency=data["portfolio"].currency).first()
|
|
@@ -19,7 +19,7 @@ class DailyPortfolioCashFlowImportHandler(ImportExportHandler):
|
|
|
19
19
|
|
|
20
20
|
def _deserialize(self, data):
|
|
21
21
|
data["value_date"] = datetime.strptime(data["value_date"], "%Y-%m-%d").date()
|
|
22
|
-
data["portfolio"] = Portfolio.
|
|
22
|
+
data["portfolio"] = Portfolio.all_objects.get(id=data["portfolio"])
|
|
23
23
|
if "cash" in data:
|
|
24
24
|
data["cash"] = Decimal(data["cash"])
|
|
25
25
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import math
|
|
1
2
|
from datetime import datetime
|
|
2
3
|
from decimal import Decimal
|
|
3
4
|
from typing import Any, Dict, Optional
|
|
@@ -24,6 +25,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
24
25
|
self.instrument_handler = InstrumentImportHandler(self.import_source)
|
|
25
26
|
self.register_handler = RegisterImportHandler(self.import_source)
|
|
26
27
|
self.currency_handler = CurrencyImportHandler(self.import_source)
|
|
28
|
+
self.trade_proposals = set()
|
|
27
29
|
|
|
28
30
|
def _data_changed(self, _object, change_data: Dict[str, Any], initial_data: Dict[str, Any], **kwargs):
|
|
29
31
|
if (new_register := change_data.get("register")) and (current_register := _object.register):
|
|
@@ -42,6 +44,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
42
44
|
|
|
43
45
|
if trade_proposal_id := data.pop("trade_proposal_id", None):
|
|
44
46
|
trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
|
|
47
|
+
self.trade_proposals.add(trade_proposal)
|
|
45
48
|
data["value_date"] = trade_proposal.last_effective_date
|
|
46
49
|
data["transaction_date"] = trade_proposal.trade_date
|
|
47
50
|
data["trade_proposal"] = trade_proposal
|
|
@@ -69,7 +72,10 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
69
72
|
|
|
70
73
|
for field in self.model._meta.get_fields():
|
|
71
74
|
if not (value := data.get(field.name, None)) is None and isinstance(field, models.DecimalField):
|
|
72
|
-
|
|
75
|
+
q = (
|
|
76
|
+
1 / (math.pow(10, 4))
|
|
77
|
+
) # we need that convertion mechanism otherwise there is floating point approximation error while casting to decimal and get_instance does not work as expected
|
|
78
|
+
data[field.name] = Decimal(value).quantize(Decimal(str(q)))
|
|
73
79
|
|
|
74
80
|
def _create_instance(self, data: Dict[str, Any], **kwargs) -> models.Model:
|
|
75
81
|
if "transaction_date" not in data: # we might get only book date and not transaction date
|
|
@@ -79,7 +85,6 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
79
85
|
|
|
80
86
|
def _get_instance(self, data: Dict[str, Any], history: Optional[models.QuerySet] = None, **kwargs) -> models.Model:
|
|
81
87
|
self.import_source.log += "\nGet Trade Instance."
|
|
82
|
-
|
|
83
88
|
if transaction_date := data.get("transaction_date"):
|
|
84
89
|
dates_lookup = {"transaction_date": transaction_date}
|
|
85
90
|
elif book_date := data.get("book_date"):
|
|
@@ -180,6 +185,12 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
180
185
|
if instrument.instrument_type.key == "product":
|
|
181
186
|
update_outstanding_shares_as_task.delay(instrument.id)
|
|
182
187
|
|
|
188
|
+
# if the trade import relates to a trade proposal, we reset the TP after the import to ensure it contains the deleted positions (often forgotten by user)
|
|
189
|
+
for changed_trade_proposal in self.trade_proposals:
|
|
190
|
+
changed_trade_proposal.reset_trades(
|
|
191
|
+
target_portfolio=changed_trade_proposal._build_dto().convert_to_portfolio()
|
|
192
|
+
)
|
|
193
|
+
|
|
183
194
|
def _post_processing_updated_object(self, _object):
|
|
184
195
|
if _object.marked_for_deletion:
|
|
185
196
|
_object.marked_for_deletion = False
|
|
@@ -29,7 +29,7 @@ def get_portfolio_id(row: pd.Series) -> int:
|
|
|
29
29
|
Raises: Portfolio.DoesNotExist: We raise an error intentionally if the portfolio does not exist to make the import fail
|
|
30
30
|
"""
|
|
31
31
|
iban = str(IBAN.generate(BANK_COUNTRY_CODE, bank_code=BANK_CODE, account_code=str(row["account_number"])))
|
|
32
|
-
return Portfolio.
|
|
32
|
+
return Portfolio.all_objects.get(bank_accounts__iban=iban).id
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def parse(import_source: "ImportSource") -> dict:
|
|
@@ -13,7 +13,7 @@ def parse(import_source):
|
|
|
13
13
|
|
|
14
14
|
def get_portfolio_id(row) -> int | None:
|
|
15
15
|
with suppress(Portfolio.DoesNotExist):
|
|
16
|
-
return Portfolio.
|
|
16
|
+
return Portfolio.all_objects.get(instruments__children__isin__in=[row["Isin"]]).pk
|
|
17
17
|
|
|
18
18
|
df = df[~df["Isin"].isnull()]
|
|
19
19
|
df["Trade date"] = df["Trade date"].ffill()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.0.13 on 2025-03-20 08:19
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('wbportfolio', '0074_alter_rebalancer_frequency_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='portfolio',
|
|
15
|
+
name='initial_position_date',
|
|
16
|
+
field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='portfolio',
|
|
20
|
+
name='last_position_date',
|
|
21
|
+
field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
|
|
22
|
+
),
|
|
23
|
+
migrations.AlterField(
|
|
24
|
+
model_name='trade',
|
|
25
|
+
name='transaction_subtype',
|
|
26
|
+
field=models.CharField(choices=[('REBALANCE', 'Rebalance'), ('DECREASE', 'Decrease'), ('INCREASE', 'Increase'), ('SUBSCRIPTION', 'Subscription'), ('REDEMPTION', 'Redemption'), ('BUY', 'Buy'), ('SELL', 'Sell'), ('NO_CHANGE', 'No Change')], default='BUY', max_length=32, verbose_name='Trade Type'),
|
|
27
|
+
),
|
|
28
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Generated by Django 5.0.13 on 2025-03-21 12:30
|
|
2
|
+
|
|
3
|
+
import django.db.models.expressions
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
def migrate_default_values(apps, schema_editor):
|
|
8
|
+
Transaction = apps.get_model("wbportfolio", "Transaction")
|
|
9
|
+
Trade = apps.get_model("wbportfolio", "Trade")
|
|
10
|
+
Expiry = apps.get_model("wbportfolio", "Expiry")
|
|
11
|
+
DividendTransaction = apps.get_model("wbportfolio", "DividendTransaction")
|
|
12
|
+
Transaction.objects.filter(book_date__isnull=True).update(book_date=models.F("transaction_date"))
|
|
13
|
+
Transaction.objects.filter(value_date__isnull=True).update(value_date=models.F("transaction_date"))
|
|
14
|
+
Trade.objects.filter(price_gross__isnull=True).update(price_gross=models.F("price"))
|
|
15
|
+
Expiry.objects.filter(price_gross__isnull=True).update(price_gross=models.F("price"))
|
|
16
|
+
DividendTransaction.objects.filter(price_gross__isnull=True).update(price_gross=models.F("price"))
|
|
17
|
+
Transaction.objects.filter(total_value_gross__isnull=True).update(total_value_gross=models.F("total_value"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Migration(migrations.Migration):
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
('wbportfolio', '0075_portfolio_initial_position_date_and_more'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
operations = [
|
|
27
|
+
migrations.RunPython(migrate_default_values),
|
|
28
|
+
migrations.AlterField(
|
|
29
|
+
model_name='transaction',
|
|
30
|
+
name='book_date',
|
|
31
|
+
field=models.DateField(help_text='The date that this transaction was booked.',
|
|
32
|
+
verbose_name='Trade Date'),
|
|
33
|
+
preserve_default=False,
|
|
34
|
+
),
|
|
35
|
+
migrations.AlterField(
|
|
36
|
+
model_name='transaction',
|
|
37
|
+
name='value_date',
|
|
38
|
+
field=models.DateField(help_text='The date that this transaction was valuated.',
|
|
39
|
+
verbose_name='Value Date'),
|
|
40
|
+
preserve_default=False,
|
|
41
|
+
),
|
|
42
|
+
migrations.AlterField(
|
|
43
|
+
model_name='dividendtransaction',
|
|
44
|
+
name='price',
|
|
45
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The price per share.', max_digits=16, verbose_name='Price'),
|
|
46
|
+
),
|
|
47
|
+
migrations.AlterField(
|
|
48
|
+
model_name='dividendtransaction',
|
|
49
|
+
name='price_gross',
|
|
50
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The gross price per share.', max_digits=16, verbose_name='Gross Price'),
|
|
51
|
+
preserve_default=False,
|
|
52
|
+
),
|
|
53
|
+
migrations.AlterField(
|
|
54
|
+
model_name='dividendtransaction',
|
|
55
|
+
name='shares',
|
|
56
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The number of shares that were traded.', max_digits=15, verbose_name='Shares'),
|
|
57
|
+
),
|
|
58
|
+
migrations.AlterField(
|
|
59
|
+
model_name='expiry',
|
|
60
|
+
name='price',
|
|
61
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The price per share.', max_digits=16, verbose_name='Price'),
|
|
62
|
+
),
|
|
63
|
+
migrations.AlterField(
|
|
64
|
+
model_name='expiry',
|
|
65
|
+
name='price_gross',
|
|
66
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The gross price per share.', max_digits=16, verbose_name='Gross Price'),
|
|
67
|
+
preserve_default=False,
|
|
68
|
+
),
|
|
69
|
+
migrations.AlterField(
|
|
70
|
+
model_name='expiry',
|
|
71
|
+
name='shares',
|
|
72
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The number of shares that were traded.', max_digits=15, verbose_name='Shares'),
|
|
73
|
+
),
|
|
74
|
+
migrations.AlterField(
|
|
75
|
+
model_name='trade',
|
|
76
|
+
name='price',
|
|
77
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The price per share.', max_digits=16, verbose_name='Price'),
|
|
78
|
+
),
|
|
79
|
+
migrations.AlterField(
|
|
80
|
+
model_name='trade',
|
|
81
|
+
name='price_gross',
|
|
82
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The gross price per share.', max_digits=16, verbose_name='Gross Price'),
|
|
83
|
+
preserve_default=False,
|
|
84
|
+
),
|
|
85
|
+
migrations.AlterField(
|
|
86
|
+
model_name='trade',
|
|
87
|
+
name='shares',
|
|
88
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), help_text='The number of shares that were traded.', max_digits=15, verbose_name='Shares'),
|
|
89
|
+
),
|
|
90
|
+
|
|
91
|
+
migrations.AlterField(
|
|
92
|
+
model_name='transaction',
|
|
93
|
+
name='total_value',
|
|
94
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), max_digits=20, verbose_name='Total Value'),
|
|
95
|
+
preserve_default=False,
|
|
96
|
+
),
|
|
97
|
+
migrations.AlterField(
|
|
98
|
+
model_name='transaction',
|
|
99
|
+
name='total_value_gross',
|
|
100
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0.0'), max_digits=20, verbose_name='Total Value Gross'),
|
|
101
|
+
preserve_default=False,
|
|
102
|
+
),
|
|
103
|
+
migrations.RemoveField(
|
|
104
|
+
model_name='transaction',
|
|
105
|
+
name='total_value_fx_portfolio',
|
|
106
|
+
),
|
|
107
|
+
migrations.RemoveField(
|
|
108
|
+
model_name='transaction',
|
|
109
|
+
name='total_value_gross_fx_portfolio',
|
|
110
|
+
),
|
|
111
|
+
migrations.AddField(
|
|
112
|
+
model_name='transaction',
|
|
113
|
+
name='total_value_fx_portfolio',
|
|
114
|
+
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(
|
|
115
|
+
models.F('currency_fx_rate'), '*', models.F('total_value')),
|
|
116
|
+
output_field=models.DecimalField(decimal_places=4, max_digits=20)),
|
|
117
|
+
preserve_default=False,
|
|
118
|
+
),
|
|
119
|
+
migrations.AddField(
|
|
120
|
+
model_name='transaction',
|
|
121
|
+
name='total_value_gross_fx_portfolio',
|
|
122
|
+
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('total_value_gross')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
|
|
123
|
+
preserve_default=False,
|
|
124
|
+
),
|
|
125
|
+
|
|
126
|
+
]
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -29,7 +29,6 @@ from django.dispatch import receiver
|
|
|
29
29
|
from django.utils import timezone
|
|
30
30
|
from django.utils.functional import cached_property
|
|
31
31
|
from pandas._libs.tslibs.offsets import BDay
|
|
32
|
-
from psycopg.types.range import DateRange
|
|
33
32
|
from skfolio.preprocessing import prices_to_returns
|
|
34
33
|
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
35
34
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
@@ -293,6 +292,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
293
292
|
default=False, help_text="If true, this portfolio is a composition of other portfolio"
|
|
294
293
|
)
|
|
295
294
|
updated_at = models.DateTimeField(blank=True, null=True, verbose_name="Updated At")
|
|
295
|
+
last_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
|
|
296
|
+
initial_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
|
|
297
|
+
|
|
296
298
|
bank_accounts = models.ManyToManyField(
|
|
297
299
|
to="directory.BankingContact",
|
|
298
300
|
related_name="wbportfolio_portfolios",
|
|
@@ -409,7 +411,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
409
411
|
)
|
|
410
412
|
|
|
411
413
|
def __str__(self):
|
|
412
|
-
return f"{self.id:06}
|
|
414
|
+
return f"{self.id:06}: {self.name}"
|
|
413
415
|
|
|
414
416
|
class Meta:
|
|
415
417
|
verbose_name = "Portfolio"
|
|
@@ -699,6 +701,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
699
701
|
self.evaluate_rebalancing(val_date)
|
|
700
702
|
|
|
701
703
|
self.updated_at = timezone.now()
|
|
704
|
+
if self.assets.filter(date=val_date).exists():
|
|
705
|
+
if not self.last_position_date or self.last_position_date < val_date:
|
|
706
|
+
self.last_position_date = val_date
|
|
707
|
+
if not self.initial_position_date or self.initial_position_date > val_date:
|
|
708
|
+
self.initial_position_date = val_date
|
|
702
709
|
self.save()
|
|
703
710
|
|
|
704
711
|
if compute_metrics:
|
|
@@ -972,7 +979,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
972
979
|
for position in positions:
|
|
973
980
|
position.portfolio = self
|
|
974
981
|
update_dates.add(position.date)
|
|
975
|
-
|
|
982
|
+
|
|
983
|
+
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
984
|
+
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
985
|
+
# change completely the trades of a portfolio model and drift it.
|
|
986
|
+
self.assets.filter(date__in=update_dates, is_estimated=True).delete()
|
|
976
987
|
leftover_positions_ids = list(
|
|
977
988
|
self.assets.filter(date__in=update_dates).values_list("id", flat=True)
|
|
978
989
|
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
@@ -1007,7 +1018,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1007
1018
|
@classmethod
|
|
1008
1019
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
1009
1020
|
if isinstance(portfolio_data, int):
|
|
1010
|
-
return Portfolio.
|
|
1021
|
+
return Portfolio.all_objects.get(id=portfolio_data)
|
|
1011
1022
|
instrument = portfolio_data
|
|
1012
1023
|
if isinstance(portfolio_data, dict):
|
|
1013
1024
|
instrument = instrument_handler.process_object(instrument, only_security=False, read_only=True)[0]
|
|
@@ -1259,18 +1270,6 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
|
|
|
1259
1270
|
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1260
1271
|
|
|
1261
1272
|
|
|
1262
|
-
@receiver(post_save, sender="wbportfolio.Product")
|
|
1263
|
-
@receiver(post_save, sender="wbportfolio.ProductGroup")
|
|
1264
|
-
def post_product_creation(sender, instance, created, raw, **kwargs):
|
|
1265
|
-
if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
|
|
1266
|
-
portfolio = Portfolio.objects.create(
|
|
1267
|
-
name=f"Portfolio: {instance.name}",
|
|
1268
|
-
currency=instance.currency,
|
|
1269
|
-
invested_timespan=DateRange(instance.inception_date if instance.inception_date else date.min, date.max),
|
|
1270
|
-
)
|
|
1271
|
-
InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
1273
|
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
1275
1274
|
def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
|
|
1276
1275
|
if (
|
|
@@ -14,6 +14,7 @@ from django.db.models import (
|
|
|
14
14
|
)
|
|
15
15
|
from django.db.models.functions import Greatest
|
|
16
16
|
from django.dispatch import receiver
|
|
17
|
+
from django.utils.translation import gettext_lazy as _
|
|
17
18
|
from django_fsm import FSMField, transition
|
|
18
19
|
from wbcore.contrib.ai.llm.config import add_llm_prompt
|
|
19
20
|
from wbcore.contrib.authentication.models import User
|
|
@@ -335,13 +336,13 @@ class Claim(ReferenceIDMixin, WBModel):
|
|
|
335
336
|
errors = dict()
|
|
336
337
|
|
|
337
338
|
if not self.trade:
|
|
338
|
-
errors["trade"] = ["With this status, this has to be provided."]
|
|
339
|
+
errors["trade"] = [_("With this status, this has to be provided.")]
|
|
339
340
|
|
|
340
341
|
if not self.product:
|
|
341
|
-
errors["product"] = ["With this status, this has to be provided."]
|
|
342
|
+
errors["product"] = [_("With this status, this has to be provided.")]
|
|
342
343
|
|
|
343
344
|
if not self.account:
|
|
344
|
-
errors["account"] = ["With this status, this has to be provided."]
|
|
345
|
+
errors["account"] = [_("With this status, this has to be provided.")]
|
|
345
346
|
|
|
346
347
|
# check if the specified product have a valid nav at the specified date
|
|
347
348
|
if (
|
|
@@ -350,13 +351,13 @@ class Claim(ReferenceIDMixin, WBModel):
|
|
|
350
351
|
and not product.valuations.filter(date=claim_date).exists()
|
|
351
352
|
):
|
|
352
353
|
if (prices_qs := product.valuations.filter(date__lt=claim_date)).exists():
|
|
353
|
-
errors["date"] =
|
|
354
|
+
errors["date"] = [
|
|
354
355
|
f"For product {product.name}, the latest valid valuation date before {claim_date:%Y-%m-%d} is {prices_qs.latest('date').date:%Y-%m-%d}: Please select a valid date."
|
|
355
|
-
|
|
356
|
+
]
|
|
356
357
|
else:
|
|
357
|
-
errors["date"] =
|
|
358
|
+
errors["date"] = [
|
|
358
359
|
f"There is no valuation before {claim_date:%Y-%m-%d} for product {product.name}: Please select a valid date."
|
|
359
|
-
|
|
360
|
+
]
|
|
360
361
|
return errors
|
|
361
362
|
|
|
362
363
|
@transition(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
1
3
|
from django.db import models
|
|
2
4
|
|
|
3
5
|
from wbportfolio.import_export.handlers.dividend import DividendImportHandler
|
|
@@ -10,23 +12,4 @@ class DividendTransaction(Transaction, ShareMixin, models.Model):
|
|
|
10
12
|
retrocession = models.FloatField(default=1)
|
|
11
13
|
|
|
12
14
|
def save(self, *args, **kwargs):
|
|
13
|
-
|
|
14
|
-
self.shares is not None
|
|
15
|
-
and self.price is not None
|
|
16
|
-
and self.retrocession is not None
|
|
17
|
-
and self.total_value is None
|
|
18
|
-
):
|
|
19
|
-
self.total_value = self.shares * self.price * self.retrocession
|
|
20
|
-
|
|
21
|
-
if self.price is not None and self.price_gross is None:
|
|
22
|
-
self.price_gross = self.price
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
self.price_gross is not None
|
|
26
|
-
and self.retrocession is not None
|
|
27
|
-
and self.shares is not None
|
|
28
|
-
and self.total_value_gross is None
|
|
29
|
-
):
|
|
30
|
-
self.total_value_gross = self.shares * self.price_gross * self.retrocession
|
|
31
|
-
|
|
32
|
-
super().save(*args, **kwargs)
|
|
15
|
+
super().save(*args, factor=Decimal(self.retrocession), **kwargs)
|
|
@@ -133,7 +133,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
133
133
|
trade_proposal.reset_trades(target_portfolio)
|
|
134
134
|
trade_proposal.submit()
|
|
135
135
|
if self.approve_trade_proposal_automatically and self.portfolio.can_be_rebalanced:
|
|
136
|
-
trade_proposal.approve()
|
|
136
|
+
trade_proposal.approve(replay=False)
|
|
137
137
|
except ValidationError:
|
|
138
138
|
# If we encountered a validation error, we set the trade proposal as failed
|
|
139
139
|
trade_proposal.status = TradeProposal.Status.FAILED
|
|
@@ -146,6 +146,12 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
146
146
|
def rrule(self):
|
|
147
147
|
return self.get_rrule()
|
|
148
148
|
|
|
149
|
+
def get_next_rebalancing_date(self, pivot_date: date) -> date | None:
|
|
150
|
+
for _dt in self.rrule:
|
|
151
|
+
_d = _dt.date()
|
|
152
|
+
if _d > pivot_date:
|
|
153
|
+
return _d
|
|
154
|
+
|
|
149
155
|
@property
|
|
150
156
|
def frequency_repr(self):
|
|
151
157
|
return humanize_rrule(self.rrule)
|