wbportfolio 1.47.1__py2.py3-none-any.whl → 1.49.0__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/contrib/company_portfolio/tasks.py +8 -4
- wbportfolio/dynamic_preferences_registry.py +9 -0
- wbportfolio/factories/__init__.py +1 -3
- 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/trade.py +8 -0
- wbportfolio/import_export/resources/trades.py +19 -30
- wbportfolio/metric/backends/base.py +3 -15
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/models/__init__.py +1 -2
- wbportfolio/models/asset.py +1 -1
- wbportfolio/models/portfolio.py +20 -13
- wbportfolio/models/products.py +50 -1
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +172 -67
- wbportfolio/models/transactions/trades.py +34 -25
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/preferences.py +6 -1
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/accounts.py +14 -6
- wbportfolio/risk_management/backends/exposure_portfolio.py +36 -5
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trade_proposals.py +2 -13
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tasks.py +4 -1
- wbportfolio/tests/conftest.py +1 -1
- wbportfolio/tests/models/test_portfolios.py +1 -1
- wbportfolio/tests/models/test_products.py +26 -0
- 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/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/trades.py +23 -8
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/portfolios.py +22 -7
- wbportfolio/viewsets/transactions/trade_proposals.py +21 -2
- wbportfolio/viewsets/transactions/trades.py +86 -12
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +46 -44
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from datetime import date
|
|
2
2
|
|
|
3
3
|
from celery import shared_task
|
|
4
|
+
from django.db.models import Exists, OuterRef
|
|
4
5
|
from tqdm import tqdm
|
|
5
6
|
from wbcore.contrib.currency.models import CurrencyFXRates
|
|
6
|
-
from wbcore.contrib.directory.models import Company
|
|
7
|
+
from wbcore.contrib.directory.models import Company
|
|
8
|
+
from wbcrm.models import Account
|
|
7
9
|
|
|
8
|
-
from .models import Updater
|
|
10
|
+
from .models import CompanyPortfolioData, Updater
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@shared_task(queue="portfolio")
|
|
@@ -13,7 +15,9 @@ def update_all_portfolio_data(val_date: date | None = None):
|
|
|
13
15
|
if not val_date:
|
|
14
16
|
val_date = CurrencyFXRates.objects.latest("date").date
|
|
15
17
|
updater = Updater(val_date)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
qs = Company.objects.annotate(
|
|
19
|
+
has_account=Exists(Account.objects.filter(owner=OuterRef("pk"))),
|
|
20
|
+
has_portfolio_data=Exists(CompanyPortfolioData.objects.filter(company=OuterRef("pk"))),
|
|
21
|
+
)
|
|
18
22
|
for company in tqdm(qs, total=qs.count()):
|
|
19
23
|
updater.update_company_data(company)
|
|
@@ -56,3 +56,12 @@ class AccountHoldingReconciliationNotificationBodyUpdate(LongStringPreference):
|
|
|
56
56
|
section = portfolio
|
|
57
57
|
name = "account_holding_reconciliation_notification_body_update"
|
|
58
58
|
default = """A reconcilation has been updated and requires your review. Please review the reconciliation for the account {account} on {reconciliation_date}."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@global_preferences_registry.register
|
|
62
|
+
class ProductTerminationNoticePeriod(IntegerPreference):
|
|
63
|
+
section = portfolio
|
|
64
|
+
name = "product_termination_notice_period"
|
|
65
|
+
default = 6 * 30 # approx 6 months
|
|
66
|
+
|
|
67
|
+
verbose_name = "Product Termination notice period"
|
|
@@ -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
|
|
@@ -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")
|
|
@@ -24,6 +24,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
24
24
|
self.instrument_handler = InstrumentImportHandler(self.import_source)
|
|
25
25
|
self.register_handler = RegisterImportHandler(self.import_source)
|
|
26
26
|
self.currency_handler = CurrencyImportHandler(self.import_source)
|
|
27
|
+
self.trade_proposals = set()
|
|
27
28
|
|
|
28
29
|
def _data_changed(self, _object, change_data: Dict[str, Any], initial_data: Dict[str, Any], **kwargs):
|
|
29
30
|
if (new_register := change_data.get("register")) and (current_register := _object.register):
|
|
@@ -42,6 +43,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
42
43
|
|
|
43
44
|
if trade_proposal_id := data.pop("trade_proposal_id", None):
|
|
44
45
|
trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
|
|
46
|
+
self.trade_proposals.add(trade_proposal)
|
|
45
47
|
data["value_date"] = trade_proposal.last_effective_date
|
|
46
48
|
data["transaction_date"] = trade_proposal.trade_date
|
|
47
49
|
data["trade_proposal"] = trade_proposal
|
|
@@ -180,6 +182,12 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
180
182
|
if instrument.instrument_type.key == "product":
|
|
181
183
|
update_outstanding_shares_as_task.delay(instrument.id)
|
|
182
184
|
|
|
185
|
+
# 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)
|
|
186
|
+
for changed_trade_proposal in self.trade_proposals:
|
|
187
|
+
changed_trade_proposal.reset_trades(
|
|
188
|
+
target_portfolio=changed_trade_proposal._build_dto().convert_to_portfolio()
|
|
189
|
+
)
|
|
190
|
+
|
|
183
191
|
def _post_processing_updated_object(self, _object):
|
|
184
192
|
if _object.marked_for_deletion:
|
|
185
193
|
_object.marked_for_deletion = False
|
|
@@ -5,7 +5,7 @@ from import_export.widgets import ForeignKeyWidget
|
|
|
5
5
|
from wbcore.contrib.io.resources import FilterModelResource
|
|
6
6
|
from wbfdm.models import Instrument
|
|
7
7
|
|
|
8
|
-
from wbportfolio.models import Trade
|
|
8
|
+
from wbportfolio.models import Trade
|
|
9
9
|
|
|
10
10
|
fake = Faker()
|
|
11
11
|
|
|
@@ -15,48 +15,37 @@ class TradeProposalTradeResource(FilterModelResource):
|
|
|
15
15
|
Trade Resource class to use to import trade from the trade proposal
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
def __init__(self, **kwargs):
|
|
19
|
-
self.trade_proposal = TradeProposal.objects.get(pk=kwargs["trade_proposal_id"])
|
|
20
|
-
super().__init__(**kwargs)
|
|
21
|
-
|
|
22
|
-
def before_import(self, dataset, **kwargs):
|
|
23
|
-
Trade.objects.filter(trade_proposal=self.trade_proposal).delete()
|
|
24
|
-
|
|
25
|
-
def get_or_init_instance(self, instance_loader, row):
|
|
26
|
-
try:
|
|
27
|
-
return Trade.objects.get(
|
|
28
|
-
trade_proposal=self.trade_proposal, underlying_instrument=row["underlying_instrument"]
|
|
29
|
-
)
|
|
30
|
-
except Trade.DoesNotExist:
|
|
31
|
-
return Trade(
|
|
32
|
-
trade_proposal=self.trade_proposal,
|
|
33
|
-
underlying_instrument=row["underlying_instrument"],
|
|
34
|
-
transaction_subtype=Trade.Type.BUY if row["weighting"] > 0 else Trade.Type.SELL,
|
|
35
|
-
currency=row["underlying_instrument"].currency,
|
|
36
|
-
transaction_date=self.trade_proposal.trade_date,
|
|
37
|
-
portfolio=self.trade_proposal.portfolio,
|
|
38
|
-
weighting=row["weighting"],
|
|
39
|
-
status=Trade.Status.DRAFT,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
18
|
DUMMY_FIELD_MAP = {
|
|
43
|
-
"
|
|
19
|
+
"underlying_instrument__isin": lambda: rstr.xeger("([A-Z]{2}[A-Z0-9]{9}[0-9]{1})"),
|
|
20
|
+
"underlying_instrument__ticker": "AAA",
|
|
21
|
+
"underlying_instrument__name": "stock name",
|
|
44
22
|
"weighting": 1.0,
|
|
45
23
|
"shares": 1000.2536,
|
|
46
24
|
"comment": lambda: fake.sentence(),
|
|
47
25
|
"order": 1,
|
|
48
26
|
}
|
|
49
|
-
|
|
50
|
-
column_name="
|
|
27
|
+
underlying_instrument__isin = fields.Field(
|
|
28
|
+
column_name="underlying_instrument__isin",
|
|
51
29
|
attribute="underlying_instrument",
|
|
52
30
|
widget=ForeignKeyWidget(Instrument, field="isin"),
|
|
53
31
|
)
|
|
32
|
+
underlying_instrument__name = fields.Field(
|
|
33
|
+
column_name="underlying_instrument__name",
|
|
34
|
+
attribute="underlying_instrument",
|
|
35
|
+
widget=ForeignKeyWidget(Instrument, field="name"),
|
|
36
|
+
)
|
|
37
|
+
underlying_instrument__ticker = fields.Field(
|
|
38
|
+
column_name="underlying_instrument__ticker",
|
|
39
|
+
attribute="underlying_instrument",
|
|
40
|
+
widget=ForeignKeyWidget(Instrument, field="ticker"),
|
|
41
|
+
)
|
|
54
42
|
|
|
55
43
|
class Meta:
|
|
56
|
-
import_id_fields = ("underlying_instrument",)
|
|
57
44
|
fields = (
|
|
58
45
|
"id",
|
|
59
|
-
"
|
|
46
|
+
"underlying_instrument__isin",
|
|
47
|
+
"underlying_instrument__name",
|
|
48
|
+
"underlying_instrument__ticker",
|
|
60
49
|
"weighting",
|
|
61
50
|
"shares",
|
|
62
51
|
"comment",
|
|
@@ -2,12 +2,11 @@ from datetime import date
|
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pandas as pd
|
|
5
|
-
from django.db.models import
|
|
5
|
+
from django.db.models import QuerySet
|
|
6
6
|
from wbfdm.contrib.metric.backends.base import AbstractBackend, Metric
|
|
7
7
|
from wbfdm.contrib.metric.exceptions import MetricInvalidParameterException
|
|
8
|
-
from wbfdm.models import InstrumentType
|
|
9
8
|
|
|
10
|
-
from wbportfolio.models import AssetPosition,
|
|
9
|
+
from wbportfolio.models import AssetPosition, Portfolio
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class PortfolioMetricBaseBackend(AbstractBackend[Portfolio]):
|
|
@@ -66,18 +65,7 @@ class PortfolioMetricBaseBackend(AbstractBackend[Portfolio]):
|
|
|
66
65
|
raise MetricInvalidParameterException()
|
|
67
66
|
|
|
68
67
|
def get_queryset(self) -> QuerySet[Portfolio]:
|
|
69
|
-
product_portfolios = (
|
|
70
|
-
super()
|
|
71
|
-
.get_queryset()
|
|
72
|
-
.annotate(
|
|
73
|
-
has_product=Exists(
|
|
74
|
-
InstrumentPortfolioThroughModel.objects.filter(
|
|
75
|
-
instrument__instrument_type=InstrumentType.PRODUCT, portfolio=OuterRef("pk")
|
|
76
|
-
)
|
|
77
|
-
)
|
|
78
|
-
)
|
|
79
|
-
.filter(has_product=True, is_active=True)
|
|
80
|
-
)
|
|
68
|
+
product_portfolios = super().get_queryset().filter_active_and_tracked()
|
|
81
69
|
try:
|
|
82
70
|
last_position_date = (
|
|
83
71
|
AssetPosition.objects.filter(portfolio__in=product_portfolios, is_estimated=False).latest("date").date
|
|
@@ -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
|
+
]
|
wbportfolio/models/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Import CRM relevant data
|
|
2
|
-
|
|
2
|
+
from .roles import PortfolioRole
|
|
3
3
|
from .adjustments import Adjustment
|
|
4
4
|
from .asset import AssetPosition, AssetPositionGroupBy
|
|
5
5
|
from .custodians import Custodian
|
|
@@ -16,6 +16,5 @@ from .portfolio_cash_targets import PortfolioCashTarget
|
|
|
16
16
|
from .portfolio_cash_flow import DailyPortfolioCashFlow
|
|
17
17
|
from .portfolio_swing_pricings import PortfolioSwingPricing
|
|
18
18
|
from .registers import Register
|
|
19
|
-
from .roles import PortfolioRole
|
|
20
19
|
from .transactions import *
|
|
21
20
|
from .reconciliations import AccountReconciliation, AccountReconciliationLine
|
wbportfolio/models/asset.py
CHANGED
|
@@ -528,7 +528,7 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
528
528
|
shares=self._shares,
|
|
529
529
|
date=self.date,
|
|
530
530
|
asset_valuation_date=self.asset_valuation_date,
|
|
531
|
-
instrument_type=self.underlying_quote.
|
|
531
|
+
instrument_type=self.underlying_quote.security_instrument_type.id,
|
|
532
532
|
currency=self.underlying_quote.currency.id,
|
|
533
533
|
country=self.underlying_quote.country.id if self.underlying_quote.country else None,
|
|
534
534
|
is_cash=self.underlying_quote.is_cash,
|
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
|
|
@@ -129,6 +128,15 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
129
128
|
& (Q(invested_timespan__endswith__gt=val_date) | Q(invested_timespan__endswith__isnull=True))
|
|
130
129
|
)
|
|
131
130
|
|
|
131
|
+
def filter_active_and_tracked(self):
|
|
132
|
+
return self.annotate(
|
|
133
|
+
has_product=Exists(
|
|
134
|
+
InstrumentPortfolioThroughModel.objects.filter(
|
|
135
|
+
instrument__instrument_type=InstrumentType.PRODUCT, portfolio=OuterRef("pk")
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
).filter((Q(has_product=True) | Q(is_manageable=True)) & Q(is_active=True) & Q(is_tracked=True))
|
|
139
|
+
|
|
132
140
|
def to_dependency_iterator(self, val_date: date) -> Iterable["Portfolio"]:
|
|
133
141
|
"""
|
|
134
142
|
A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
|
|
@@ -171,6 +179,9 @@ class DefaultPortfolioManager(ActiveObjectManager):
|
|
|
171
179
|
def filter_invested_at_date(self, val_date: date):
|
|
172
180
|
return self.get_queryset().filter_invested_at_date(val_date)
|
|
173
181
|
|
|
182
|
+
def filter_active_and_tracked(self):
|
|
183
|
+
return self.get_queryset().filter_active_and_tracked()
|
|
184
|
+
|
|
174
185
|
|
|
175
186
|
class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
|
|
176
187
|
def get_queryset(self):
|
|
@@ -281,6 +292,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
281
292
|
default=False, help_text="If true, this portfolio is a composition of other portfolio"
|
|
282
293
|
)
|
|
283
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
|
+
|
|
284
298
|
bank_accounts = models.ManyToManyField(
|
|
285
299
|
to="directory.BankingContact",
|
|
286
300
|
related_name="wbportfolio_portfolios",
|
|
@@ -687,6 +701,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
687
701
|
self.evaluate_rebalancing(val_date)
|
|
688
702
|
|
|
689
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
|
|
690
709
|
self.save()
|
|
691
710
|
|
|
692
711
|
if compute_metrics:
|
|
@@ -1247,18 +1266,6 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
|
|
|
1247
1266
|
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1248
1267
|
|
|
1249
1268
|
|
|
1250
|
-
@receiver(post_save, sender="wbportfolio.Product")
|
|
1251
|
-
@receiver(post_save, sender="wbportfolio.ProductGroup")
|
|
1252
|
-
def post_product_creation(sender, instance, created, raw, **kwargs):
|
|
1253
|
-
if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
|
|
1254
|
-
portfolio = Portfolio.objects.create(
|
|
1255
|
-
name=f"Portfolio: {instance.name}",
|
|
1256
|
-
currency=instance.currency,
|
|
1257
|
-
invested_timespan=DateRange(instance.inception_date if instance.inception_date else date.min, date.max),
|
|
1258
|
-
)
|
|
1259
|
-
InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
1269
|
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
1263
1270
|
def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
|
|
1264
1271
|
if (
|
wbportfolio/models/products.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from contextlib import suppress
|
|
2
|
-
from datetime import date, datetime, time
|
|
2
|
+
from datetime import date, datetime, time, timedelta
|
|
3
3
|
from decimal import Decimal
|
|
4
4
|
|
|
5
5
|
from celery import shared_task
|
|
@@ -25,6 +25,9 @@ from pandas.tseries.offsets import BDay
|
|
|
25
25
|
from wbcore.contrib.ai.llm.config import add_llm_prompt
|
|
26
26
|
from wbcore.contrib.currency.models import CurrencyFXRates
|
|
27
27
|
from wbcore.contrib.directory.models import Entry
|
|
28
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
29
|
+
from wbcore.contrib.notifications.utils import create_notification_type
|
|
30
|
+
from wbcore.permissions.shortcuts import get_internal_users
|
|
28
31
|
from wbcore.signals import pre_merge
|
|
29
32
|
from wbcore.utils.enum import ChoiceEnum
|
|
30
33
|
from wbcrm.models.accounts import Account
|
|
@@ -34,6 +37,8 @@ from wbfdm.models.instruments.instruments import InstrumentManager, InstrumentTy
|
|
|
34
37
|
from wbportfolio.models.llm.wbcrm.analyze_relationship import get_holding_prompt
|
|
35
38
|
from wbportfolio.models.portfolio_relationship import InstrumentPortfolioThroughModel
|
|
36
39
|
|
|
40
|
+
from ..preferences import get_product_termination_notice_period
|
|
41
|
+
from . import PortfolioRole
|
|
37
42
|
from .mixins.instruments import PMSInstrument, PMSInstrumentAbstractModel
|
|
38
43
|
|
|
39
44
|
|
|
@@ -316,6 +321,17 @@ class Product(PMSInstrumentAbstractModel):
|
|
|
316
321
|
class Meta:
|
|
317
322
|
verbose_name = "Product"
|
|
318
323
|
verbose_name_plural = "Products"
|
|
324
|
+
notification_types = [
|
|
325
|
+
create_notification_type(
|
|
326
|
+
"wbportfolio.product.termination_notice",
|
|
327
|
+
"Product Termination Notice",
|
|
328
|
+
"Sends a notification when a product is expected termination in the near future",
|
|
329
|
+
True,
|
|
330
|
+
True,
|
|
331
|
+
True,
|
|
332
|
+
is_lock=True,
|
|
333
|
+
),
|
|
334
|
+
]
|
|
319
335
|
|
|
320
336
|
def pre_save(self):
|
|
321
337
|
super().pre_save()
|
|
@@ -341,6 +357,39 @@ class Product(PMSInstrumentAbstractModel):
|
|
|
341
357
|
computed_str += f" ({self.isin})"
|
|
342
358
|
return computed_str
|
|
343
359
|
|
|
360
|
+
def check_and_notify_product_termination_on_date(self, today: date) -> bool:
|
|
361
|
+
"""
|
|
362
|
+
Checks if today is the expected notice date for product termination and sends notifications if applicable.
|
|
363
|
+
|
|
364
|
+
The expected notice date is calculated by subtracting the product termination notice period from the product's delisted date. Notifications are sent to users associated with the product's portfolio roles.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
today (date): The date to check against the expected notice date.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
bool: True if termination is due, False otherwise.
|
|
371
|
+
"""
|
|
372
|
+
if self.delisted_date:
|
|
373
|
+
product_termination_expected_notice_date = self.delisted_date - timedelta(
|
|
374
|
+
days=get_product_termination_notice_period()
|
|
375
|
+
)
|
|
376
|
+
if today == product_termination_expected_notice_date:
|
|
377
|
+
roles = PortfolioRole.objects.filter(
|
|
378
|
+
(models.Q(instrument=self) | models.Q(instrument__isnull=True))
|
|
379
|
+
& (models.Q(start__isnull=True) | models.Q(start__gte=today))
|
|
380
|
+
& (models.Q(end__isnull=True) | models.Q(end__lte=today))
|
|
381
|
+
& models.Q(person__user_account__isnull=False)
|
|
382
|
+
)
|
|
383
|
+
for user in get_internal_users().filter(is_active=True, id__in=roles.values("person__user_account")):
|
|
384
|
+
send_notification(
|
|
385
|
+
code="wbportfolio.product.termination_notice",
|
|
386
|
+
title="Product Termination Notice",
|
|
387
|
+
body=f"The product {self} will be terminated on the {self.delisted_date:%Y-%m-%d}",
|
|
388
|
+
user=user,
|
|
389
|
+
)
|
|
390
|
+
return True
|
|
391
|
+
return False
|
|
392
|
+
|
|
344
393
|
@property
|
|
345
394
|
def white_label_product(self):
|
|
346
395
|
return self.white_label_customers.all().count() > 0
|
|
@@ -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)
|