wbportfolio 1.48.0__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 (33) hide show
  1. wbportfolio/factories/__init__.py +1 -3
  2. wbportfolio/factories/portfolios.py +0 -12
  3. wbportfolio/factories/product_groups.py +8 -1
  4. wbportfolio/factories/products.py +18 -0
  5. wbportfolio/factories/trades.py +5 -1
  6. wbportfolio/import_export/handlers/trade.py +8 -0
  7. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  8. wbportfolio/models/portfolio.py +8 -13
  9. wbportfolio/models/transactions/rebalancing.py +7 -1
  10. wbportfolio/models/transactions/trade_proposals.py +146 -49
  11. wbportfolio/models/transactions/trades.py +16 -11
  12. wbportfolio/pms/trading/handler.py +1 -1
  13. wbportfolio/pms/typing.py +3 -0
  14. wbportfolio/rebalancing/models/composite.py +14 -1
  15. wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
  16. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  17. wbportfolio/serializers/portfolios.py +26 -0
  18. wbportfolio/serializers/transactions/trades.py +13 -0
  19. wbportfolio/tests/models/test_portfolios.py +1 -1
  20. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  21. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  22. wbportfolio/tests/signals.py +1 -1
  23. wbportfolio/tests/viewsets/test_performances.py +2 -1
  24. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  25. wbportfolio/viewsets/configs/display/trades.py +23 -8
  26. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  27. wbportfolio/viewsets/portfolios.py +22 -7
  28. wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
  29. wbportfolio/viewsets/transactions/trades.py +86 -12
  30. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
  31. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +33 -31
  32. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
  33. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
@@ -136,6 +136,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
136
136
  REDEMPTION = "REDEMPTION", "Redemption"
137
137
  BUY = "BUY", "Buy"
138
138
  SELL = "SELL", "Sell"
139
+ NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
139
140
 
140
141
  external_identifier2 = models.CharField(
141
142
  max_length=255,
@@ -224,7 +225,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
224
225
  source=Status.DRAFT,
225
226
  target=GET_STATE(
226
227
  lambda self, **kwargs: (
227
- self.Status.SUBMIT if self.underlying_quote_price is not None else self.Status.FAILED
228
+ self.Status.SUBMIT if self.last_underlying_quote_price is not None else self.Status.FAILED
228
229
  ),
229
230
  states=[Status.SUBMIT, Status.FAILED],
230
231
  ),
@@ -245,7 +246,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
245
246
  on_error="FAILED",
246
247
  )
247
248
  def submit(self, by=None, description=None, **kwargs):
248
- if not self.underlying_quote_price:
249
+ if not self.last_underlying_quote_price:
249
250
  self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
250
251
 
251
252
  def can_submit(self):
@@ -263,13 +264,15 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
263
264
  self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
264
265
 
265
266
  @cached_property
266
- def underlying_quote_price(self) -> InstrumentPrice | None:
267
+ def last_underlying_quote_price(self) -> InstrumentPrice | None:
267
268
  try:
269
+ # we try t0 first
268
270
  return InstrumentPrice.objects.filter_only_valid_prices().get(
269
- instrument=self.underlying_instrument, date=self.value_date
271
+ instrument=self.underlying_instrument, date=self.transaction_date
270
272
  )
271
273
  except InstrumentPrice.DoesNotExist:
272
274
  with suppress(InstrumentPrice.DoesNotExist):
275
+ # we fall back to the latest price before t0
273
276
  return (
274
277
  InstrumentPrice.objects.filter_only_valid_prices()
275
278
  .filter(instrument=self.underlying_instrument, date__lte=self.value_date)
@@ -296,7 +299,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
296
299
  },
297
300
  )
298
301
  def execute(self, **kwargs):
299
- if self.underlying_quote_price:
302
+ if self.last_underlying_quote_price:
300
303
  asset, created = AssetPosition.unannotated_objects.update_or_create(
301
304
  underlying_quote=self.underlying_instrument,
302
305
  portfolio_created=None,
@@ -305,9 +308,9 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
305
308
  defaults={
306
309
  "initial_currency_fx_rate": self.currency_fx_rate,
307
310
  "weighting": self._target_weight,
308
- "initial_price": self.underlying_quote_price.net_value,
311
+ "initial_price": self.last_underlying_quote_price.net_value,
309
312
  "initial_shares": None,
310
- "underlying_quote_price": self.underlying_quote_price,
313
+ "underlying_quote_price": self.last_underlying_quote_price,
311
314
  "asset_valuation_date": self.transaction_date,
312
315
  "currency": self.currency,
313
316
  "is_estimated": False,
@@ -316,7 +319,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
316
319
  asset.set_weighting(self._target_weight)
317
320
 
318
321
  def can_execute(self):
319
- if not self.underlying_quote_price:
322
+ if not self.last_underlying_quote_price:
320
323
  return {"underlying_instrument": "Cannot execute a trade without a valid quote price"}
321
324
  if not self.portfolio.is_manageable:
322
325
  return {"portfolio": "The portfolio needs to be a model portfolio in order to execute this trade manually"}
@@ -494,8 +497,8 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
494
497
  self.portfolio = self.trade_proposal.portfolio
495
498
  self.transaction_date = self.trade_proposal.trade_date
496
499
  self.value_date = self.trade_proposal.last_effective_date
497
- if self._effective_shares:
498
- self.shares = self._effective_shares * self.weighting
500
+ if not self.portfolio.only_weighting:
501
+ self.shares = self.trade_proposal.get_estimated_shares(self.weighting, self.underlying_instrument)
499
502
 
500
503
  if not self.custodian and self.bank:
501
504
  self.custodian = Custodian.get_by_mapping(self.bank)
@@ -513,7 +516,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
513
516
  self.total_value_gross = self.price_gross * self.shares
514
517
  self.transaction_type = Transaction.Type.TRADE
515
518
 
516
- if self.transaction_subtype is None:
519
+ if self.transaction_subtype is None or self.trade_proposal:
517
520
  # if subtype not provided, we extract it automatically from the existing data.
518
521
  self._set_transaction_subtype()
519
522
  if self.id and hasattr(self, "claims"):
@@ -525,6 +528,8 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
525
528
  super().save(*args, **kwargs)
526
529
 
527
530
  def _set_transaction_subtype(self):
531
+ if self.weighting == 0:
532
+ self.transaction_subtype = Trade.Type.NO_CHANGE
528
533
  if self.underlying_instrument.instrument_type.key == "product":
529
534
  if self.shares is not None:
530
535
  if self.shares > 0:
@@ -62,7 +62,7 @@ class TradingService:
62
62
  Test the given value against all the validators on the field,
63
63
  and either raise a `ValidationError` or simply return.
64
64
  """
65
- TradeBatch(validated_trades).validate()
65
+ # TradeBatch(validated_trades).validate()
66
66
  if self.effective_portfolio:
67
67
  for trade in validated_trades:
68
68
  if (
wbportfolio/pms/typing.py CHANGED
@@ -70,6 +70,9 @@ class Portfolio:
70
70
  def to_df(self):
71
71
  return pd.DataFrame([asdict(pos) for pos in self.positions])
72
72
 
73
+ def to_dict(self) -> dict[int, Decimal]:
74
+ return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
75
+
73
76
  def __len__(self):
74
77
  return len(self.positions)
75
78
 
@@ -2,6 +2,7 @@ from decimal import Decimal
2
2
 
3
3
  from django.core.exceptions import ObjectDoesNotExist
4
4
 
5
+ from wbportfolio.models import Trade
5
6
  from wbportfolio.pms.typing import Portfolio, Position
6
7
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
7
8
  from wbportfolio.rebalancing.decorators import register
@@ -11,11 +12,23 @@ from wbportfolio.rebalancing.decorators import register
11
12
  class CompositeRebalancing(AbstractRebalancingModel):
12
13
  @property
13
14
  def base_assets(self) -> dict[int, Decimal]:
15
+ """
16
+ Return a dictionary representation (instrument_id: target weight) of this trade proposal
17
+ Returns:
18
+ A dictionary representation
19
+
20
+ """
14
21
  try:
15
22
  latest_trade_proposal = self.portfolio.trade_proposals.filter(
16
23
  status="APPROVED", trade_date__lte=self.trade_date
17
24
  ).latest("trade_date")
18
- return latest_trade_proposal.base_assets
25
+ return {
26
+ v["underlying_instrument"]: v["target_weight"]
27
+ for v in latest_trade_proposal.trades.all()
28
+ .annotate_base_info()
29
+ .filter(status=Trade.Status.EXECUTED)
30
+ .values("underlying_instrument", "target_weight")
31
+ }
19
32
  except ObjectDoesNotExist:
20
33
  return dict()
21
34
 
@@ -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,6 +152,10 @@ 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]:
@@ -180,7 +205,11 @@ class RuleBackend(
180
205
  & df["primary_classification"].isin(list(map(lambda o: o.id, self.classifications)))
181
206
  )
182
207
  ]
183
-
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]
184
213
  return df
185
214
 
186
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
 
@@ -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",
@@ -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