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.

Files changed (46) hide show
  1. wbportfolio/factories/__init__.py +1 -3
  2. wbportfolio/factories/dividends.py +1 -0
  3. wbportfolio/factories/portfolios.py +0 -12
  4. wbportfolio/factories/product_groups.py +8 -1
  5. wbportfolio/factories/products.py +18 -0
  6. wbportfolio/factories/trades.py +5 -1
  7. wbportfolio/import_export/handlers/dividend.py +1 -1
  8. wbportfolio/import_export/handlers/fees.py +1 -1
  9. wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -1
  10. wbportfolio/import_export/handlers/trade.py +13 -2
  11. wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -1
  12. wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -1
  13. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  14. wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py +126 -0
  15. wbportfolio/models/portfolio.py +15 -16
  16. wbportfolio/models/transactions/claim.py +8 -7
  17. wbportfolio/models/transactions/dividends.py +3 -20
  18. wbportfolio/models/transactions/rebalancing.py +7 -1
  19. wbportfolio/models/transactions/trade_proposals.py +163 -63
  20. wbportfolio/models/transactions/trades.py +24 -22
  21. wbportfolio/models/transactions/transactions.py +37 -37
  22. wbportfolio/pms/trading/handler.py +1 -1
  23. wbportfolio/pms/typing.py +3 -0
  24. wbportfolio/rebalancing/models/composite.py +14 -1
  25. wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
  26. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  27. wbportfolio/serializers/portfolios.py +26 -0
  28. wbportfolio/serializers/transactions/trades.py +13 -0
  29. wbportfolio/tests/models/test_portfolios.py +2 -2
  30. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  31. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  32. wbportfolio/tests/signals.py +1 -1
  33. wbportfolio/tests/viewsets/test_performances.py +2 -1
  34. wbportfolio/viewsets/configs/display/assets.py +0 -11
  35. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  36. wbportfolio/viewsets/configs/display/products.py +0 -13
  37. wbportfolio/viewsets/configs/display/trades.py +24 -17
  38. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  39. wbportfolio/viewsets/configs/endpoints/transactions.py +6 -0
  40. wbportfolio/viewsets/portfolios.py +39 -8
  41. wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
  42. wbportfolio/viewsets/transactions/trades.py +105 -13
  43. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/METADATA +1 -1
  44. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/RECORD +46 -43
  45. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/WHEEL +0 -0
  46. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/licenses/LICENSE +0 -0
@@ -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",
@@ -33,7 +33,7 @@ class TestPortfolioModel(PortfolioTestMixin):
33
33
  assert portfolio.id is not None
34
34
 
35
35
  def test_str(self, portfolio):
36
- assert str(portfolio) == f"{portfolio.id:06} ({portfolio.name})"
36
+ assert str(portfolio) == f"{portfolio.id:06}: {portfolio.name}"
37
37
 
38
38
  def test_get_assets(self, portfolio, product, cash, asset_position_factory):
39
39
  asset_position_factory.create_batch(4, portfolio=portfolio, underlying_instrument=product)
@@ -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
@@ -0,0 +1,381 @@
1
+ # Import necessary modules
2
+ from datetime import date, timedelta
3
+ from decimal import Decimal
4
+ from unittest.mock import call, patch
5
+
6
+ import pytest
7
+ from faker import Faker
8
+ from pandas._libs.tslibs.offsets import BDay, MonthEnd
9
+
10
+ from wbportfolio.models import Portfolio, RebalancingModel, TradeProposal
11
+ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
12
+ from wbportfolio.pms.typing import Position
13
+
14
+ fake = Faker()
15
+
16
+
17
+ # Mark tests to use Django's database
18
+ @pytest.mark.django_db
19
+ class TestTradeProposal:
20
+ # Test that the checked object is correctly set to the portfolio
21
+ def test_checked_object(self, trade_proposal):
22
+ """
23
+ Verify that the checked object is the portfolio associated with the trade proposal.
24
+ """
25
+ assert trade_proposal.checked_object == trade_proposal.portfolio
26
+
27
+ # Test that the evaluation date matches the trade date
28
+ def test_check_evaluation_date(self, trade_proposal):
29
+ """
30
+ Ensure the evaluation date is the same as the trade date.
31
+ """
32
+ assert trade_proposal.check_evaluation_date == trade_proposal.trade_date
33
+
34
+ # Test the validated trading service functionality
35
+ def test_validated_trading_service(self, trade_proposal, asset_position_factory, trade_factory):
36
+ """
37
+ Validate that the effective and target portfolios are correctly calculated.
38
+ """
39
+ effective_date = (trade_proposal.trade_date - BDay(1)).date()
40
+
41
+ # Create asset positions for testing
42
+ a1 = asset_position_factory.create(
43
+ portfolio=trade_proposal.portfolio, date=effective_date, weighting=Decimal("0.3")
44
+ )
45
+ a2 = asset_position_factory.create(
46
+ portfolio=trade_proposal.portfolio, date=effective_date, weighting=Decimal("0.7")
47
+ )
48
+
49
+ # Create trades for testing
50
+ t1 = trade_factory.create(
51
+ trade_proposal=trade_proposal,
52
+ weighting=Decimal("0.05"),
53
+ portfolio=trade_proposal.portfolio,
54
+ transaction_date=trade_proposal.trade_date,
55
+ underlying_instrument=a1.underlying_quote,
56
+ )
57
+ t2 = trade_factory.create(
58
+ trade_proposal=trade_proposal,
59
+ weighting=Decimal("-0.05"),
60
+ portfolio=trade_proposal.portfolio,
61
+ transaction_date=trade_proposal.trade_date,
62
+ underlying_instrument=a2.underlying_quote,
63
+ )
64
+
65
+ # Get the validated trading service
66
+ validated_trading_service = trade_proposal.validated_trading_service
67
+
68
+ # Assert effective and target portfolios are as expected
69
+ assert validated_trading_service.effective_portfolio.to_dict() == {
70
+ a1.underlying_quote.id: a1.weighting,
71
+ a2.underlying_quote.id: a2.weighting,
72
+ }
73
+ assert validated_trading_service.target_portfolio.to_dict() == {
74
+ a1.underlying_quote.id: a1.weighting + t1.weighting,
75
+ a2.underlying_quote.id: a2.weighting + t2.weighting,
76
+ }
77
+
78
+ # Test the calculation of the last effective date
79
+ def test_last_effective_date(self, trade_proposal, asset_position_factory):
80
+ """
81
+ Verify the last effective date is correctly determined based on asset positions.
82
+ """
83
+ # Without any positions, it should be the day before the trade date
84
+ assert (
85
+ trade_proposal.last_effective_date == (trade_proposal.trade_date - BDay(1)).date()
86
+ ), "Last effective date without position should be t-1"
87
+
88
+ # Create an asset position before the trade date
89
+ a1 = asset_position_factory.create(
90
+ portfolio=trade_proposal.portfolio, date=(trade_proposal.trade_date - BDay(5)).date()
91
+ )
92
+ a_noise = asset_position_factory.create(portfolio=trade_proposal.portfolio, date=trade_proposal.trade_date) # noqa
93
+
94
+ # The last effective date should still be the day before the trade date due to caching
95
+ assert (
96
+ trade_proposal.last_effective_date == (trade_proposal.trade_date - BDay(1)).date()
97
+ ), "last effective date is cached, so it won't change as is"
98
+
99
+ # Reset the cache property to recalculate
100
+ del trade_proposal.last_effective_date
101
+
102
+ # Now it should be the date of the latest position before the trade date
103
+ assert (
104
+ trade_proposal.last_effective_date == a1.date
105
+ ), "last effective date is the latest position strictly lower than trade date"
106
+
107
+ # Test finding the previous trade proposal
108
+ def test_previous_trade_proposal(self, trade_proposal_factory):
109
+ """
110
+ Ensure the previous trade proposal is correctly identified as the last approved proposal before the current one.
111
+ """
112
+ tp = trade_proposal_factory.create()
113
+ tp_previous_submit = trade_proposal_factory.create( # noqa
114
+ portfolio=tp.portfolio, status=TradeProposal.Status.SUBMIT, trade_date=(tp.trade_date - BDay(1)).date()
115
+ )
116
+ tp_previous_approve = trade_proposal_factory.create(
117
+ portfolio=tp.portfolio, status=TradeProposal.Status.APPROVED, trade_date=(tp.trade_date - BDay(2)).date()
118
+ )
119
+ tp_next_approve = trade_proposal_factory.create( # noqa
120
+ portfolio=tp.portfolio, status=TradeProposal.Status.APPROVED, trade_date=(tp.trade_date + BDay(1)).date()
121
+ )
122
+
123
+ # The previous valid trade proposal should be the approved one strictly before the current proposal
124
+ assert (
125
+ tp.previous_trade_proposal == tp_previous_approve
126
+ ), "the previous valid trade proposal is the strictly before and approved trade proposal"
127
+
128
+ # Test finding the next trade proposal
129
+ def test_next_trade_proposal(self, trade_proposal_factory):
130
+ """
131
+ Verify the next trade proposal is correctly identified as the first approved proposal after the current one.
132
+ """
133
+ tp = trade_proposal_factory.create()
134
+ tp_next_submit = trade_proposal_factory.create( # noqa
135
+ portfolio=tp.portfolio, status=TradeProposal.Status.SUBMIT, trade_date=(tp.trade_date + BDay(1)).date()
136
+ )
137
+ tp_next_approve = trade_proposal_factory.create(
138
+ portfolio=tp.portfolio, status=TradeProposal.Status.APPROVED, trade_date=(tp.trade_date + BDay(2)).date()
139
+ )
140
+ tp_previous_approve = trade_proposal_factory.create( # noqa
141
+ portfolio=tp.portfolio, status=TradeProposal.Status.APPROVED, trade_date=(tp.trade_date - BDay(1)).date()
142
+ )
143
+
144
+ # The next valid trade proposal should be the approved one strictly after the current proposal
145
+ assert (
146
+ tp.next_trade_proposal == tp_next_approve
147
+ ), "the next valid trade proposal is the strictly after and approved trade proposal"
148
+
149
+ # Test getting the default target portfolio
150
+ def test__get_default_target_portfolio(self, trade_proposal, asset_position_factory):
151
+ """
152
+ Ensure the default target portfolio is set to the effective portfolio from the day before the trade date.
153
+ """
154
+ effective_date = (trade_proposal.trade_date - BDay(1)).date()
155
+
156
+ # Create asset positions for testing
157
+ a1 = asset_position_factory.create(
158
+ portfolio=trade_proposal.portfolio, date=effective_date, weighting=Decimal("0.3")
159
+ )
160
+ a2 = asset_position_factory.create(
161
+ portfolio=trade_proposal.portfolio, date=effective_date, weighting=Decimal("0.7")
162
+ )
163
+ asset_position_factory.create(portfolio=trade_proposal.portfolio, date=trade_proposal.trade_date) # noise
164
+
165
+ # The default target portfolio should match the effective portfolio
166
+ assert trade_proposal._get_default_target_portfolio().to_dict() == {
167
+ a1.underlying_quote.id: a1.weighting,
168
+ a2.underlying_quote.id: a2.weighting,
169
+ }
170
+
171
+ # Test getting the default target portfolio with a rebalancing model
172
+ @patch.object(RebalancingModel, "get_target_portfolio")
173
+ def test__get_default_target_portfolio_with_rebalancer_model(self, mock_fct, trade_proposal, rebalancer_factory):
174
+ """
175
+ Verify that the target portfolio is correctly obtained from a rebalancing model.
176
+ """
177
+ # Expected target portfolio from the rebalancing model
178
+ expected_target_portfolio = PortfolioDTO(
179
+ positions=(Position(underlying_instrument=1, weighting=Decimal(1), date=trade_proposal.trade_date),)
180
+ )
181
+ mock_fct.return_value = expected_target_portfolio
182
+
183
+ # Create a rebalancer for testing
184
+ rebalancer = rebalancer_factory.create(
185
+ portfolio=trade_proposal.portfolio, parameters={"rebalancer_parameter": "A"}
186
+ )
187
+ trade_proposal.rebalancing_model = rebalancer.rebalancing_model
188
+ trade_proposal.save()
189
+
190
+ # Additional keyword arguments for the rebalancing model
191
+ extra_kwargs = {"test": "test"}
192
+
193
+ # Combine rebalancer parameters with extra keyword arguments
194
+ expected_kwargs = rebalancer.parameters
195
+ expected_kwargs.update(extra_kwargs)
196
+
197
+ # Assert the target portfolio matches the expected output from the rebalancing model
198
+ assert (
199
+ trade_proposal._get_default_target_portfolio(**extra_kwargs) == expected_target_portfolio
200
+ ), "We expect the target portfolio to be whatever is returned by the rebalancer model"
201
+ mock_fct.assert_called_once_with(
202
+ trade_proposal.portfolio, trade_proposal.trade_date, trade_proposal.last_effective_date, **expected_kwargs
203
+ )
204
+
205
+ # Test normalizing trades
206
+ def test_normalize_trades(self, trade_proposal, trade_factory):
207
+ """
208
+ Ensure trades are normalized to sum up to 1, handling quantization errors.
209
+ """
210
+ # Create trades for testing
211
+ t1 = trade_factory.create(
212
+ trade_proposal=trade_proposal,
213
+ transaction_date=trade_proposal.trade_date,
214
+ portfolio=trade_proposal.portfolio,
215
+ weighting=Decimal(0.2),
216
+ )
217
+ t2 = trade_factory.create(
218
+ trade_proposal=trade_proposal,
219
+ transaction_date=trade_proposal.trade_date,
220
+ portfolio=trade_proposal.portfolio,
221
+ weighting=Decimal(0.26),
222
+ )
223
+ t3 = trade_factory.create(
224
+ trade_proposal=trade_proposal,
225
+ transaction_date=trade_proposal.trade_date,
226
+ portfolio=trade_proposal.portfolio,
227
+ weighting=Decimal(0.14),
228
+ )
229
+
230
+ # Normalize trades
231
+ trade_proposal.normalize_trades()
232
+
233
+ # Refresh trades from the database
234
+ t1.refresh_from_db()
235
+ t2.refresh_from_db()
236
+ t3.refresh_from_db()
237
+
238
+ # Expected normalized weights
239
+ normalized_t1_weight = Decimal("0.333333")
240
+ normalized_t2_weight = Decimal("0.433333")
241
+ normalized_t3_weight = Decimal("0.233333")
242
+
243
+ # Calculate quantization error
244
+ quantize_error = Decimal(1) - (normalized_t1_weight + normalized_t2_weight + normalized_t3_weight)
245
+
246
+ # Assert quantization error exists and weights are normalized correctly
247
+ assert quantize_error
248
+ assert t1.weighting == normalized_t1_weight
249
+ assert t2.weighting == normalized_t2_weight + quantize_error # Add quantize error to the largest position
250
+ assert t3.weighting == normalized_t3_weight
251
+
252
+ # Test resetting trades
253
+ def test_reset_trades(self, trade_proposal, instrument_factory, asset_position_factory):
254
+ """
255
+ Verify trades are correctly reset based on effective and target portfolios.
256
+ """
257
+ effective_date = trade_proposal.last_effective_date
258
+
259
+ # Create instruments for testing
260
+ i1 = instrument_factory.create(currency=trade_proposal.portfolio.currency)
261
+ i2 = instrument_factory.create(currency=trade_proposal.portfolio.currency)
262
+ i3 = instrument_factory.create(currency=trade_proposal.portfolio.currency)
263
+
264
+ # Build initial effective portfolio constituting only from two positions of i1 and i2
265
+ asset_position_factory.create(
266
+ portfolio=trade_proposal.portfolio, date=effective_date, underlying_instrument=i1, weighting=Decimal("0.7")
267
+ )
268
+ asset_position_factory.create(
269
+ portfolio=trade_proposal.portfolio, date=effective_date, underlying_instrument=i2, weighting=Decimal("0.3")
270
+ )
271
+
272
+ # build the target portfolio
273
+ target_portfolio = PortfolioDTO(
274
+ positions=(
275
+ Position(underlying_instrument=i2.id, date=trade_proposal.trade_date, weighting=Decimal("0.4")),
276
+ Position(underlying_instrument=i3.id, date=trade_proposal.trade_date, weighting=Decimal("0.6")),
277
+ )
278
+ )
279
+
280
+ # Reset trades
281
+ trade_proposal.reset_trades(target_portfolio=target_portfolio)
282
+
283
+ # Get trades for each instrument
284
+ t1 = trade_proposal.trades.get(underlying_instrument=i1)
285
+ t2 = trade_proposal.trades.get(underlying_instrument=i2)
286
+ t3 = trade_proposal.trades.get(underlying_instrument=i3)
287
+
288
+ # Assert trade weights are correctly reset
289
+ assert t1.weighting == Decimal("-0.7")
290
+ assert t2.weighting == Decimal("0.1")
291
+ assert t3.weighting == Decimal("0.6")
292
+
293
+ # Test replaying trade proposals
294
+ @patch.object(Portfolio, "batch_portfolio")
295
+ def test_replay(self, mock_fct, trade_proposal_factory):
296
+ """
297
+ Ensure replaying trade proposals correctly calls batch_portfolio for each period.
298
+ """
299
+ mock_fct.return_value = None
300
+
301
+ # Create approved trade proposals for testing
302
+ tp0 = trade_proposal_factory.create(status=TradeProposal.Status.APPROVED)
303
+ tp1 = trade_proposal_factory.create(
304
+ portfolio=tp0.portfolio,
305
+ status=TradeProposal.Status.APPROVED,
306
+ trade_date=(tp0.trade_date + MonthEnd(1)).date(),
307
+ )
308
+ tp2 = trade_proposal_factory.create(
309
+ portfolio=tp0.portfolio,
310
+ status=TradeProposal.Status.APPROVED,
311
+ trade_date=(tp1.trade_date + MonthEnd(1)).date(),
312
+ )
313
+
314
+ # Replay trade proposals
315
+ tp0.replay()
316
+
317
+ # Expected calls to batch_portfolio
318
+ expected_calls = [
319
+ call(tp0.trade_date, tp1.trade_date - timedelta(days=1)),
320
+ call(tp1.trade_date, tp2.trade_date - timedelta(days=1)),
321
+ call(tp2.trade_date, date.today()),
322
+ ]
323
+
324
+ # Assert batch_portfolio was called as expected
325
+ mock_fct.assert_has_calls(expected_calls)
326
+
327
+ # Test stopping replay on a non-approved proposal
328
+ tp1.status = TradeProposal.Status.FAILED
329
+ tp1.save()
330
+ expected_calls = [call(tp0.trade_date, tp1.trade_date - timedelta(days=1))]
331
+ mock_fct.assert_has_calls(expected_calls)
332
+
333
+ # Test estimating shares for a trade
334
+ @patch.object(Portfolio, "get_total_asset_value")
335
+ def test_get_estimated_shares(
336
+ self, mock_fct, trade_proposal, trade_factory, instrument_price_factory, instrument_factory
337
+ ):
338
+ """
339
+ Verify shares estimation based on trade weighting and instrument price.
340
+ """
341
+ portfolio = trade_proposal.portfolio
342
+ instrument = instrument_factory.create(currency=portfolio.currency)
343
+ trade = trade_factory.create(
344
+ trade_proposal=trade_proposal,
345
+ transaction_date=trade_proposal.trade_date,
346
+ portfolio=portfolio,
347
+ underlying_instrument=instrument,
348
+ )
349
+ trade.refresh_from_db()
350
+ underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=trade.transaction_date)
351
+ mock_fct.return_value = Decimal(1_000_000) # 1 million cash
352
+
353
+ # Assert estimated shares are correctly calculated
354
+ assert (
355
+ trade_proposal.get_estimated_shares(trade.weighting, trade.underlying_instrument)
356
+ == Decimal(1_000_000) * trade.weighting / underlying_quote_price.net_value
357
+ )
358
+
359
+ @patch.object(Portfolio, "get_total_asset_value")
360
+ def test_get_estimated_target_cash(self, mock_fct, trade_proposal, trade_factory, cash_factory):
361
+ mock_fct.return_value = Decimal(1_000_000) # 1 million cash
362
+ cash = cash_factory.create(currency=trade_proposal.portfolio.currency)
363
+ trade_factory.create( # equity trade
364
+ trade_proposal=trade_proposal,
365
+ transaction_date=trade_proposal.trade_date,
366
+ portfolio=trade_proposal.portfolio,
367
+ weighting=Decimal("0.7"),
368
+ )
369
+ trade_factory.create( # cash trade
370
+ trade_proposal=trade_proposal,
371
+ transaction_date=trade_proposal.trade_date,
372
+ portfolio=trade_proposal.portfolio,
373
+ underlying_instrument=cash,
374
+ weighting=Decimal("0.2"),
375
+ )
376
+
377
+ target_cash_weight, total_target_shares = trade_proposal.get_estimated_target_cash(
378
+ trade_proposal.portfolio.currency
379
+ )
380
+ assert target_cash_weight == Decimal("0.2") + Decimal("1.0") - (Decimal("0.7") + Decimal("0.2"))
381
+ assert total_target_shares == Decimal(1_000_000) * Decimal("0.3")
@@ -3,6 +3,7 @@ from decimal import Decimal
3
3
 
4
4
  import pytest
5
5
  from faker import Faker
6
+ from pandas._libs.tslibs.offsets import BDay
6
7
 
7
8
  from wbportfolio.models import Product, Trade
8
9
 
@@ -203,3 +204,16 @@ class TestTradeInstrumentPrice:
203
204
  subscription_trade1.refresh_from_db()
204
205
  assert subscription_trade1.internal_trade == internal_trade
205
206
  assert subscription_trade1.marked_as_internal is True
207
+
208
+ def test_last_underlying_quote_price(self, weekday, trade_factory, instrument_price_factory):
209
+ trade = trade_factory.create(transaction_date=weekday, value_date=(weekday - BDay(1)).date())
210
+ assert trade.last_underlying_quote_price is None
211
+ del trade.last_underlying_quote_price
212
+
213
+ # test that underlying quote price returns any price found at transaction_date, or then at value_date (in that order)
214
+ p0 = instrument_price_factory.create(instrument=trade.underlying_instrument, date=trade.value_date)
215
+ assert trade.last_underlying_quote_price == p0
216
+ del trade.last_underlying_quote_price
217
+
218
+ p1 = instrument_price_factory.create(instrument=trade.underlying_instrument, date=trade.transaction_date)
219
+ assert trade.last_underlying_quote_price == p1
@@ -2,10 +2,10 @@ from django.dispatch import receiver
2
2
  from wbcore.contrib.currency.factories import CurrencyFXRatesFactory
3
3
  from wbcore.contrib.directory.factories import CompanyFactory
4
4
  from wbcore.test.signals import custom_update_kwargs, get_custom_factory
5
+ from wbfdm.factories import InstrumentPriceFactory
5
6
 
6
7
  from wbportfolio.factories import (
7
8
  CustomerTradeFactory,
8
- InstrumentPriceFactory,
9
9
  ProductFactory,
10
10
  ProductPortfolioRoleFactory,
11
11
  TradeProposalFactory,