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.

Files changed (46) hide show
  1. wbportfolio/contrib/company_portfolio/tasks.py +8 -4
  2. wbportfolio/dynamic_preferences_registry.py +9 -0
  3. wbportfolio/factories/__init__.py +1 -3
  4. wbportfolio/factories/portfolios.py +0 -12
  5. wbportfolio/factories/product_groups.py +8 -1
  6. wbportfolio/factories/products.py +18 -0
  7. wbportfolio/factories/trades.py +5 -1
  8. wbportfolio/import_export/handlers/trade.py +8 -0
  9. wbportfolio/import_export/resources/trades.py +19 -30
  10. wbportfolio/metric/backends/base.py +3 -15
  11. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  12. wbportfolio/models/__init__.py +1 -2
  13. wbportfolio/models/asset.py +1 -1
  14. wbportfolio/models/portfolio.py +20 -13
  15. wbportfolio/models/products.py +50 -1
  16. wbportfolio/models/transactions/rebalancing.py +7 -1
  17. wbportfolio/models/transactions/trade_proposals.py +172 -67
  18. wbportfolio/models/transactions/trades.py +34 -25
  19. wbportfolio/pms/trading/handler.py +1 -1
  20. wbportfolio/pms/typing.py +3 -0
  21. wbportfolio/preferences.py +6 -1
  22. wbportfolio/rebalancing/models/composite.py +14 -1
  23. wbportfolio/risk_management/backends/accounts.py +14 -6
  24. wbportfolio/risk_management/backends/exposure_portfolio.py +36 -5
  25. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  26. wbportfolio/serializers/portfolios.py +26 -0
  27. wbportfolio/serializers/transactions/trade_proposals.py +2 -13
  28. wbportfolio/serializers/transactions/trades.py +13 -0
  29. wbportfolio/tasks.py +4 -1
  30. wbportfolio/tests/conftest.py +1 -1
  31. wbportfolio/tests/models/test_portfolios.py +1 -1
  32. wbportfolio/tests/models/test_products.py +26 -0
  33. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  34. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  35. wbportfolio/tests/signals.py +1 -1
  36. wbportfolio/tests/viewsets/test_performances.py +2 -1
  37. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  38. wbportfolio/viewsets/configs/display/trades.py +23 -8
  39. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  40. wbportfolio/viewsets/portfolios.py +22 -7
  41. wbportfolio/viewsets/transactions/trade_proposals.py +21 -2
  42. wbportfolio/viewsets/transactions/trades.py +86 -12
  43. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
  44. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +46 -44
  45. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
  46. {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
- dff = dff[(dff[self.field.value] > numerical_range[0]) & (dff[self.field.value] < numerical_range[1])]
143
- if not dff.empty:
144
- for id, row in dff.to_dict("index").items():
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 periodically_update_outstanding_shares_for_active_products():
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")
@@ -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
+ )