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
|
@@ -101,6 +101,24 @@ class RuleBackend(
|
|
|
101
101
|
)
|
|
102
102
|
_classifications = ClassificationRepresentationSerializer(many=True, source="parameters__classifications")
|
|
103
103
|
|
|
104
|
+
extra_filter_field = wb_serializers.ChoiceField(
|
|
105
|
+
choices=cls.Field.choices,
|
|
106
|
+
default=None,
|
|
107
|
+
allow_null=True,
|
|
108
|
+
label="Extra Filter Field",
|
|
109
|
+
help_text="Specify if we need to narrow done the position applying a filter on that field and the corresponding range",
|
|
110
|
+
)
|
|
111
|
+
extra_filter_field_lower_bound = wb_serializers.FloatField(
|
|
112
|
+
default=None,
|
|
113
|
+
allow_null=True,
|
|
114
|
+
label="Extra Filter Field Lower bound",
|
|
115
|
+
)
|
|
116
|
+
extra_filter_field_upper_bound = wb_serializers.FloatField(
|
|
117
|
+
default=None,
|
|
118
|
+
allow_null=True,
|
|
119
|
+
label="Extra Filter Field Lower bound",
|
|
120
|
+
)
|
|
121
|
+
|
|
104
122
|
@classmethod
|
|
105
123
|
def get_parameter_fields(cls):
|
|
106
124
|
return [
|
|
@@ -111,6 +129,9 @@ class RuleBackend(
|
|
|
111
129
|
"currencies",
|
|
112
130
|
"countries",
|
|
113
131
|
"classifications",
|
|
132
|
+
"extra_filter_field",
|
|
133
|
+
"extra_filter_field_lower_bound",
|
|
134
|
+
"extra_filter_field_upper_bound",
|
|
114
135
|
]
|
|
115
136
|
|
|
116
137
|
return RuleBackendSerializer
|
|
@@ -131,17 +152,22 @@ class RuleBackend(
|
|
|
131
152
|
repr["Only Countries"] = ", ".join(map(lambda o: o.code_2, self.countries))
|
|
132
153
|
if self.classifications:
|
|
133
154
|
repr["Only Classifications"] = ", ".join(map(lambda o: o.name, self.classifications))
|
|
155
|
+
if self.extra_filter_field:
|
|
156
|
+
repr["Extra Filter Field"] = (
|
|
157
|
+
f"{self.extra_filter_field}=[{self.extra_filter_field_lower_bound},{self.extra_filter_field_upper_bound}["
|
|
158
|
+
)
|
|
134
159
|
return repr
|
|
135
160
|
|
|
136
161
|
def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
|
|
137
162
|
if not (df := self._filter_df(portfolio.to_df())).empty:
|
|
138
163
|
df = df[[self.group_by.value, self.field.value]].groupby(self.group_by.value).sum().astype(float)
|
|
139
164
|
for threshold in self.thresholds:
|
|
140
|
-
dff = df.copy()
|
|
141
165
|
numerical_range = threshold.numerical_range
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
incident_df = df[
|
|
167
|
+
(df[self.field.value] >= numerical_range[0]) & (df[self.field.value] < numerical_range[1])
|
|
168
|
+
]
|
|
169
|
+
if not incident_df.empty:
|
|
170
|
+
for id, row in incident_df.to_dict("index").items():
|
|
145
171
|
obj, obj_repr = self._get_obj_repr(id)
|
|
146
172
|
severity: RiskIncidentType = threshold.severity
|
|
147
173
|
if self.field == self.Field.WEIGHTING:
|
|
@@ -165,9 +191,9 @@ class RuleBackend(
|
|
|
165
191
|
return df
|
|
166
192
|
if self.is_cash is True or self.is_cash is False:
|
|
167
193
|
df = df[df["is_cash"] == self.is_cash]
|
|
168
|
-
|
|
169
194
|
if self.asset_classes:
|
|
170
195
|
df = df[df["instrument_type"].isin(list(map(lambda o: o.id, self.asset_classes)))]
|
|
196
|
+
|
|
171
197
|
if self.countries:
|
|
172
198
|
df = df[(~df["country"].isnull() & df["country"].isin(list(map(lambda o: o.id, self.countries))))]
|
|
173
199
|
if self.currencies:
|
|
@@ -179,6 +205,11 @@ class RuleBackend(
|
|
|
179
205
|
& df["primary_classification"].isin(list(map(lambda o: o.id, self.classifications)))
|
|
180
206
|
)
|
|
181
207
|
]
|
|
208
|
+
if self.extra_filter_field:
|
|
209
|
+
if (lower_bound := self.extra_filter_field_lower_bound) is not None:
|
|
210
|
+
df = df[df[self.extra_filter_field] >= lower_bound]
|
|
211
|
+
if (upper_bound := self.extra_filter_field_upper_bound) is not None:
|
|
212
|
+
df = df[df[self.extra_filter_field] < upper_bound]
|
|
182
213
|
return df
|
|
183
214
|
|
|
184
215
|
def _get_obj_repr(self, pivot_object_id) -> tuple[models.Model | None, str]:
|
|
@@ -93,3 +93,50 @@ class TestExposurePortfolioRuleModel(PortfolioTestMixin):
|
|
|
93
93
|
ExposurePortfolioRuleBackend.GroupbyChoices.FAVORITE_CLASSIFICATION,
|
|
94
94
|
]:
|
|
95
95
|
assert incidents[0].breached_object == classification
|
|
96
|
+
|
|
97
|
+
def test_exposure_rule_with_extra_filter_field(
|
|
98
|
+
self, weekday, portfolio, asset_position_factory, instrument_price_factory, instrument_factory
|
|
99
|
+
):
|
|
100
|
+
parameters = {
|
|
101
|
+
"group_by": "instrument_type",
|
|
102
|
+
"field": ExposurePortfolioRuleBackend.Field.WEIGHTING.value,
|
|
103
|
+
"extra_filter_field": "market_capitalization_usd",
|
|
104
|
+
"extra_filter_field_lower_bound": 1000.0,
|
|
105
|
+
"extra_filter_field_upper_bound": 10000.0,
|
|
106
|
+
}
|
|
107
|
+
exposure_portfolio_backend = ExposurePortfolioRuleBackend(
|
|
108
|
+
weekday,
|
|
109
|
+
portfolio,
|
|
110
|
+
parameters,
|
|
111
|
+
[RuleThresholdFactory.create(range=NumericRange(lower=0.05, upper=None))], # type: ignore
|
|
112
|
+
)
|
|
113
|
+
i1 = instrument_factory.create()
|
|
114
|
+
i2 = instrument_factory.create(instrument_type=i1.instrument_type)
|
|
115
|
+
i3 = instrument_factory.create(instrument_type=i1.instrument_type)
|
|
116
|
+
instrument_price_factory.create(instrument=i1, date=weekday, market_capitalization=1000)
|
|
117
|
+
instrument_price_factory.create(instrument=i2, date=weekday, market_capitalization=9999)
|
|
118
|
+
instrument_price_factory.create(instrument=i3, date=weekday, market_capitalization=10001)
|
|
119
|
+
asset_position_factory.create(
|
|
120
|
+
date=weekday,
|
|
121
|
+
weighting=0.025,
|
|
122
|
+
underlying_instrument=i1,
|
|
123
|
+
portfolio=portfolio,
|
|
124
|
+
)
|
|
125
|
+
asset_position_factory.create(
|
|
126
|
+
date=weekday,
|
|
127
|
+
weighting=0.025,
|
|
128
|
+
underlying_instrument=i2,
|
|
129
|
+
portfolio=portfolio,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
asset_position_factory.create(
|
|
133
|
+
date=weekday,
|
|
134
|
+
weighting=0.95,
|
|
135
|
+
underlying_instrument=i3,
|
|
136
|
+
portfolio=portfolio,
|
|
137
|
+
)
|
|
138
|
+
incidents = list(exposure_portfolio_backend.check_rule())
|
|
139
|
+
assert len(incidents) == 1
|
|
140
|
+
incident = incidents[0]
|
|
141
|
+
assert incident.breached_object_repr == i1.instrument_type
|
|
142
|
+
assert incident.breached_value == '<span style="color:green">+5.00%</span>'
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
1
3
|
from rest_framework.reverse import reverse
|
|
2
4
|
from wbcore import serializers as wb_serializers
|
|
3
5
|
from wbcore.contrib.currency.serializers import CurrencyRepresentationSerializer
|
|
4
6
|
from wbcore.contrib.directory.models import BankingContact
|
|
7
|
+
from wbfdm.serializers import InstrumentRepresentationSerializer
|
|
5
8
|
|
|
6
9
|
from wbportfolio.models import Portfolio, PortfolioPortfolioThroughModel
|
|
7
10
|
from wbportfolio.serializers.rebalancing import RebalancerRepresentationSerializer
|
|
@@ -21,12 +24,25 @@ class PortfolioRepresentationSerializer(wb_serializers.RepresentationSerializer)
|
|
|
21
24
|
|
|
22
25
|
class PortfolioModelSerializer(wb_serializers.ModelSerializer):
|
|
23
26
|
_currency = CurrencyRepresentationSerializer(source="currency")
|
|
27
|
+
_hedged_currency = CurrencyRepresentationSerializer(source="hedged_currency")
|
|
24
28
|
_depends_on = PortfolioRepresentationSerializer(source="depends_on", many=True)
|
|
25
29
|
automatic_rebalancer = wb_serializers.PrimaryKeyRelatedField(read_only=True)
|
|
26
30
|
_automatic_rebalancer = RebalancerRepresentationSerializer(source="automatic_rebalancer")
|
|
31
|
+
_instruments = InstrumentRepresentationSerializer(source="instruments", many=True)
|
|
27
32
|
|
|
28
33
|
create_index = wb_serializers.BooleanField(write_only=True, default=False)
|
|
29
34
|
|
|
35
|
+
last_asset_under_management_usd = wb_serializers.FloatField(read_only=True)
|
|
36
|
+
last_positions = wb_serializers.FloatField(read_only=True)
|
|
37
|
+
last_trade_proposal_date = wb_serializers.DateField(read_only=True)
|
|
38
|
+
next_expected_trade_proposal_date = wb_serializers.SerializerMethodField(read_only=True)
|
|
39
|
+
|
|
40
|
+
def get_next_expected_trade_proposal_date(self, obj):
|
|
41
|
+
if (automatic_rebalancer := getattr(obj, "automatic_rebalancer", None)) and (
|
|
42
|
+
_d := automatic_rebalancer.get_next_rebalancing_date(date.today())
|
|
43
|
+
):
|
|
44
|
+
return _d.strftime("%Y-%m-%d")
|
|
45
|
+
|
|
30
46
|
def create(self, validated_data):
|
|
31
47
|
create_index = validated_data.pop("create_index", False)
|
|
32
48
|
obj = super().create(validated_data)
|
|
@@ -133,8 +149,12 @@ class PortfolioModelSerializer(wb_serializers.ModelSerializer):
|
|
|
133
149
|
"updated_at",
|
|
134
150
|
"_depends_on",
|
|
135
151
|
"depends_on",
|
|
152
|
+
"_instruments",
|
|
153
|
+
"instruments",
|
|
136
154
|
"_currency",
|
|
137
155
|
"currency",
|
|
156
|
+
"_hedged_currency",
|
|
157
|
+
"hedged_currency",
|
|
138
158
|
"automatic_rebalancer",
|
|
139
159
|
"_automatic_rebalancer",
|
|
140
160
|
"invested_timespan",
|
|
@@ -145,6 +165,12 @@ class PortfolioModelSerializer(wb_serializers.ModelSerializer):
|
|
|
145
165
|
"is_composition",
|
|
146
166
|
"_additional_resources",
|
|
147
167
|
"create_index",
|
|
168
|
+
"initial_position_date",
|
|
169
|
+
"last_position_date",
|
|
170
|
+
"last_asset_under_management_usd",
|
|
171
|
+
"last_positions",
|
|
172
|
+
"last_trade_proposal_date",
|
|
173
|
+
"next_expected_trade_proposal_date",
|
|
148
174
|
)
|
|
149
175
|
|
|
150
176
|
|
|
@@ -10,10 +10,7 @@ from .. import PortfolioRepresentationSerializer, RebalancingModelRepresentation
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
13
|
-
rebalancing_model = wb_serializers.PrimaryKeyRelatedField(
|
|
14
|
-
queryset=RebalancingModel.objects.all(),
|
|
15
|
-
default=DefaultFromView("portfolio.automatic_rebalancer.rebalancing_model"),
|
|
16
|
-
)
|
|
13
|
+
rebalancing_model = wb_serializers.PrimaryKeyRelatedField(queryset=RebalancingModel.objects.all(), required=False)
|
|
17
14
|
_rebalancing_model = RebalancingModelRepresentationSerializer(source="rebalancing_model")
|
|
18
15
|
target_portfolio = wb_serializers.PrimaryKeyRelatedField(
|
|
19
16
|
queryset=Portfolio.objects.all(), write_only=True, required=False, default=DefaultFromView("portfolio")
|
|
@@ -32,15 +29,7 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
32
29
|
obj = super().create(validated_data)
|
|
33
30
|
|
|
34
31
|
target_portfolio_dto = None
|
|
35
|
-
if (
|
|
36
|
-
target_portfolio
|
|
37
|
-
and not rebalancing_model
|
|
38
|
-
and (
|
|
39
|
-
last_effective_date := target_portfolio.get_latest_asset_position_date(
|
|
40
|
-
obj.trade_date, with_estimated=True
|
|
41
|
-
)
|
|
42
|
-
)
|
|
43
|
-
):
|
|
32
|
+
if target_portfolio and not rebalancing_model and (last_effective_date := obj.last_effective_date):
|
|
44
33
|
target_portfolio_dto = target_portfolio._build_dto(last_effective_date)
|
|
45
34
|
try:
|
|
46
35
|
obj.reset_trades(target_portfolio=target_portfolio_dto)
|
|
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|
|
4
4
|
from rest_framework import serializers
|
|
5
5
|
from rest_framework.reverse import reverse
|
|
6
6
|
from wbcore import serializers as wb_serializers
|
|
7
|
+
from wbfdm.serializers import InvestableInstrumentRepresentationSerializer
|
|
7
8
|
|
|
8
9
|
from wbportfolio.models import PortfolioRole, Trade, TradeProposal
|
|
9
10
|
from wbportfolio.models.transactions.claim import Claim
|
|
@@ -212,6 +213,14 @@ class TradeModelSerializer(TransactionModelSerializer):
|
|
|
212
213
|
|
|
213
214
|
|
|
214
215
|
class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
216
|
+
underlying_instrument_isin = wb_serializers.CharField(read_only=True)
|
|
217
|
+
underlying_instrument_ticker = wb_serializers.CharField(read_only=True)
|
|
218
|
+
underlying_instrument_refinitiv_identifier_code = wb_serializers.CharField(read_only=True)
|
|
219
|
+
underlying_instrument_instrument_type = wb_serializers.CharField(read_only=True)
|
|
220
|
+
_underlying_instrument = InvestableInstrumentRepresentationSerializer(
|
|
221
|
+
source="underlying_instrument", label_key="name"
|
|
222
|
+
)
|
|
223
|
+
|
|
215
224
|
status = wb_serializers.ChoiceField(default=Trade.Status.DRAFT, choices=Trade.Status.choices)
|
|
216
225
|
target_weight = wb_serializers.DecimalField(max_digits=16, decimal_places=6, required=False, default=0)
|
|
217
226
|
effective_weight = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
@@ -249,6 +258,10 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
249
258
|
fields = (
|
|
250
259
|
"id",
|
|
251
260
|
"shares",
|
|
261
|
+
"underlying_instrument_isin",
|
|
262
|
+
"underlying_instrument_ticker",
|
|
263
|
+
"underlying_instrument_refinitiv_identifier_code",
|
|
264
|
+
"underlying_instrument_instrument_type",
|
|
252
265
|
"underlying_instrument",
|
|
253
266
|
"_underlying_instrument",
|
|
254
267
|
"transaction_subtype",
|
wbportfolio/tasks.py
CHANGED
|
@@ -10,10 +10,13 @@ from .fdm.tasks import * # noqa
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@shared_task(queue="portfolio")
|
|
13
|
-
def
|
|
13
|
+
def daily_active_product_task(today: date | None = None):
|
|
14
|
+
if not today:
|
|
15
|
+
today = date.today()
|
|
14
16
|
qs = Product.active_objects.all()
|
|
15
17
|
for product in tqdm(qs, total=qs.count()):
|
|
16
18
|
update_outstanding_shares_as_task(product.id)
|
|
19
|
+
product.check_and_notify_product_termination_on_date(today)
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
@shared_task(queue="portfolio")
|
wbportfolio/tests/conftest.py
CHANGED
|
@@ -117,7 +117,7 @@ register(ContinentFactory)
|
|
|
117
117
|
|
|
118
118
|
register(CompanyFactory)
|
|
119
119
|
register(PersonFactory)
|
|
120
|
-
register(InternalUserFactory)
|
|
120
|
+
register(InternalUserFactory, "internal_user")
|
|
121
121
|
register(EntryFactory)
|
|
122
122
|
register(CustomerStatusFactory)
|
|
123
123
|
register(CompanyTypeFactory)
|
|
@@ -1062,7 +1062,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1062
1062
|
|
|
1063
1063
|
# we approve the rebalancing trade proposal
|
|
1064
1064
|
assert rebalancing_trade_proposal.status == "SUBMIT"
|
|
1065
|
-
rebalancing_trade_proposal.approve()
|
|
1065
|
+
rebalancing_trade_proposal.approve(no_replay=True)
|
|
1066
1066
|
rebalancing_trade_proposal.save()
|
|
1067
1067
|
|
|
1068
1068
|
# check that the rebalancing was applied and position reflect that
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
from datetime import timedelta
|
|
2
3
|
from decimal import Decimal
|
|
4
|
+
from unittest.mock import patch
|
|
3
5
|
|
|
4
6
|
import pytest
|
|
5
7
|
from faker import Faker
|
|
@@ -7,6 +9,7 @@ from wbfdm.models import Instrument
|
|
|
7
9
|
|
|
8
10
|
from wbportfolio.models import Product, Trade
|
|
9
11
|
|
|
12
|
+
from ...preferences import get_product_termination_notice_period
|
|
10
13
|
from .utils import PortfolioTestMixin
|
|
11
14
|
|
|
12
15
|
fake = Faker()
|
|
@@ -188,3 +191,26 @@ class TestProductModel(PortfolioTestMixin):
|
|
|
188
191
|
product.initial_high_water_mark = 400
|
|
189
192
|
product.save()
|
|
190
193
|
assert product.get_high_water_mark(p4.date) == 400
|
|
194
|
+
|
|
195
|
+
@patch("wbportfolio.models.products.send_notification")
|
|
196
|
+
def test_check_and_notify_product_termination_on_date(
|
|
197
|
+
self, mock_fct, weekday, product, manager_portfolio_role_factory, internal_user
|
|
198
|
+
):
|
|
199
|
+
assert not product.check_and_notify_product_termination_on_date(weekday)
|
|
200
|
+
|
|
201
|
+
product.delisted_date = weekday + datetime.timedelta(days=get_product_termination_notice_period())
|
|
202
|
+
product.save()
|
|
203
|
+
assert product.check_and_notify_product_termination_on_date(weekday)
|
|
204
|
+
mock_fct.assert_not_called()
|
|
205
|
+
assert not product.check_and_notify_product_termination_on_date(
|
|
206
|
+
weekday + timedelta(days=1)
|
|
207
|
+
) # one day later is not valid anymore
|
|
208
|
+
|
|
209
|
+
manager_portfolio_role_factory.create(person=internal_user.profile)
|
|
210
|
+
product.check_and_notify_product_termination_on_date(weekday)
|
|
211
|
+
mock_fct.assert_called_with(
|
|
212
|
+
code="wbportfolio.product.termination_notice",
|
|
213
|
+
title="Product Termination Notice",
|
|
214
|
+
body=f"The product {product} will be terminated on the {product.delisted_date:%Y-%m-%d}",
|
|
215
|
+
user=internal_user,
|
|
216
|
+
)
|