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.
- wbportfolio/factories/__init__.py +1 -3
- wbportfolio/factories/dividends.py +1 -0
- 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/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +1 -1
- wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -1
- wbportfolio/import_export/handlers/trade.py +13 -2
- wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -1
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py +126 -0
- wbportfolio/models/portfolio.py +15 -16
- wbportfolio/models/transactions/claim.py +8 -7
- wbportfolio/models/transactions/dividends.py +3 -20
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +163 -63
- wbportfolio/models/transactions/trades.py +24 -22
- wbportfolio/models/transactions/transactions.py +37 -37
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tests/models/test_portfolios.py +2 -2
- 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/assets.py +0 -11
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/products.py +0 -13
- wbportfolio/viewsets/configs/display/trades.py +24 -17
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/configs/endpoints/transactions.py +6 -0
- wbportfolio/viewsets/portfolios.py +39 -8
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- wbportfolio/viewsets/transactions/trades.py +105 -13
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/METADATA +1 -1
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/RECORD +46 -43
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/WHEEL +0 -0
- {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
|
|
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}
|
|
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
|
wbportfolio/tests/signals.py
CHANGED
|
@@ -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,
|