wbportfolio 1.47.1__py2.py3-none-any.whl → 1.49.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/contrib/company_portfolio/tasks.py +8 -4
- wbportfolio/dynamic_preferences_registry.py +9 -0
- wbportfolio/factories/__init__.py +1 -3
- wbportfolio/factories/portfolios.py +0 -12
- wbportfolio/factories/product_groups.py +8 -1
- wbportfolio/factories/products.py +18 -0
- wbportfolio/factories/trades.py +5 -1
- wbportfolio/import_export/handlers/trade.py +8 -0
- wbportfolio/import_export/resources/trades.py +19 -30
- wbportfolio/metric/backends/base.py +3 -15
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/models/__init__.py +1 -2
- wbportfolio/models/asset.py +1 -1
- wbportfolio/models/portfolio.py +20 -13
- wbportfolio/models/products.py +50 -1
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +172 -67
- wbportfolio/models/transactions/trades.py +34 -25
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/preferences.py +6 -1
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/accounts.py +14 -6
- wbportfolio/risk_management/backends/exposure_portfolio.py +36 -5
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trade_proposals.py +2 -13
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tasks.py +4 -1
- wbportfolio/tests/conftest.py +1 -1
- wbportfolio/tests/models/test_portfolios.py +1 -1
- wbportfolio/tests/models/test_products.py +26 -0
- wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
- wbportfolio/tests/models/transactions/test_trades.py +14 -0
- wbportfolio/tests/signals.py +1 -1
- wbportfolio/tests/viewsets/test_performances.py +2 -1
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/trades.py +23 -8
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/portfolios.py +22 -7
- wbportfolio/viewsets/transactions/trade_proposals.py +21 -2
- wbportfolio/viewsets/transactions/trades.py +86 -12
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +46 -44
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,
|
|
@@ -6,8 +6,9 @@ from faker import Faker
|
|
|
6
6
|
from pandas.tseries.offsets import BDay, BMonthEnd, BYearEnd
|
|
7
7
|
from rest_framework.reverse import reverse
|
|
8
8
|
from rest_framework.test import force_authenticate
|
|
9
|
+
from wbfdm.factories import InstrumentPriceFactory
|
|
9
10
|
|
|
10
|
-
from wbportfolio.factories import
|
|
11
|
+
from wbportfolio.factories import ProductFactory
|
|
11
12
|
from wbportfolio.viewsets.product_performance import PerformanceComparisonPandasView
|
|
12
13
|
|
|
13
14
|
fake = Faker()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
3
|
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
from wbcore.enums import Unit
|
|
4
5
|
from wbcore.metadata.configs import display as dp
|
|
5
6
|
from wbcore.metadata.configs.display import Layout, Page, default
|
|
6
7
|
from wbcore.metadata.configs.display.instance_display.shortcuts import (
|
|
@@ -17,17 +18,60 @@ class PortfolioDisplayConfig(DisplayViewConfig):
|
|
|
17
18
|
def get_list_display(self) -> Optional[dp.ListDisplay]:
|
|
18
19
|
return dp.ListDisplay(
|
|
19
20
|
fields=[
|
|
20
|
-
dp.Field(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
dp.Field(
|
|
22
|
+
label="Information",
|
|
23
|
+
open_by_default=False,
|
|
24
|
+
key=None,
|
|
25
|
+
children=[
|
|
26
|
+
dp.Field(key="name", label="Name", width=Unit.PIXEL(300)),
|
|
27
|
+
dp.Field(key="currency", label="CCY", width=Unit.PIXEL(75)),
|
|
28
|
+
dp.Field(key="hedged_currency", label="Hedged CCY", width=Unit.PIXEL(100), show="open"),
|
|
29
|
+
dp.Field(key="updated_at", label="Updated At", width=Unit.PIXEL(150)),
|
|
30
|
+
dp.Field(key="depends_on", label="Depends on", show="open", width=Unit.PIXEL(300)),
|
|
31
|
+
dp.Field(key="invested_timespan", label="Invested", show="open"),
|
|
32
|
+
dp.Field(key="instruments", label="Instruments", width=Unit.PIXEL(250)),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
35
|
+
dp.Field(
|
|
36
|
+
label="Valuation & Position",
|
|
37
|
+
open_by_default=False,
|
|
38
|
+
key=None,
|
|
39
|
+
children=[
|
|
40
|
+
dp.Field(key="initial_position_date", label="Issue Date", width=Unit.PIXEL(150)),
|
|
41
|
+
dp.Field(key="last_position_date", label="Last Position Date", width=Unit.PIXEL(150)),
|
|
42
|
+
dp.Field(
|
|
43
|
+
key="last_asset_under_management_usd", label="AUM ($)", width=Unit.PIXEL(100), show="open"
|
|
44
|
+
),
|
|
45
|
+
dp.Field(key="last_positions", label="Position", width=Unit.PIXEL(100), show="open"),
|
|
46
|
+
],
|
|
47
|
+
),
|
|
48
|
+
dp.Field(
|
|
49
|
+
label="Rebalancing",
|
|
50
|
+
open_by_default=False,
|
|
51
|
+
key=None,
|
|
52
|
+
children=[
|
|
53
|
+
dp.Field(key="automatic_rebalancer", label="Automatic Rebalancer"),
|
|
54
|
+
dp.Field(key="last_trade_proposal_date", label="Last Rebalance", width=Unit.PIXEL(250)),
|
|
55
|
+
dp.Field(
|
|
56
|
+
key="next_expected_trade_proposal_date",
|
|
57
|
+
label="Next Rebalancing",
|
|
58
|
+
width=Unit.PIXEL(250),
|
|
59
|
+
show="open",
|
|
60
|
+
),
|
|
61
|
+
],
|
|
62
|
+
),
|
|
63
|
+
dp.Field(
|
|
64
|
+
label="Administration",
|
|
65
|
+
open_by_default=False,
|
|
66
|
+
key=None,
|
|
67
|
+
children=[
|
|
68
|
+
dp.Field(key="is_manageable", label="Managed", width=Unit.PIXEL(100)),
|
|
69
|
+
dp.Field(key="is_tracked", label="Tracked", width=Unit.PIXEL(100), show="open"),
|
|
70
|
+
dp.Field(key="only_weighting", label="Only-Weight", width=Unit.PIXEL(100), show="open"),
|
|
71
|
+
dp.Field(key="is_lookthrough", label="Look through", width=Unit.PIXEL(100), show="open"),
|
|
72
|
+
dp.Field(key="is_composition", label="Composition", width=Unit.PIXEL(100), show="open"),
|
|
73
|
+
],
|
|
74
|
+
),
|
|
31
75
|
]
|
|
32
76
|
)
|
|
33
77
|
|
|
@@ -156,14 +200,14 @@ class TopDownPortfolioCompositionPandasDisplayConfig(DisplayViewConfig):
|
|
|
156
200
|
rebalancing_column_label = "Last Rebalancing"
|
|
157
201
|
effective_column_label = "Actual"
|
|
158
202
|
if self.view.last_rebalancing_date:
|
|
159
|
-
rebalancing_column_label += f" ({self.view.last_rebalancing_date:%Y
|
|
203
|
+
rebalancing_column_label += f" ({self.view.last_rebalancing_date:%Y-%m-%d})"
|
|
160
204
|
if self.view.last_effective_date:
|
|
161
205
|
effective_column_label += f" ({self.view.last_effective_date:%Y-%m-%d})"
|
|
162
206
|
return dp.ListDisplay(
|
|
163
207
|
fields=[
|
|
164
208
|
dp.Field(key="instrument", label="Instrument"),
|
|
165
|
-
dp.Field(key="
|
|
166
|
-
dp.Field(key="
|
|
209
|
+
dp.Field(key="rebalancing_weights", label=rebalancing_column_label),
|
|
210
|
+
dp.Field(key="effective_weights", label=effective_column_label),
|
|
167
211
|
],
|
|
168
212
|
tree=True,
|
|
169
213
|
tree_group_pinned="left",
|
|
@@ -300,14 +300,26 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
300
300
|
def get_list_display(self) -> Optional[dp.ListDisplay]:
|
|
301
301
|
trade_proposal = get_object_or_404(TradeProposal, pk=self.view.kwargs.get("trade_proposal_id", None))
|
|
302
302
|
fields = [
|
|
303
|
-
dp.Field(
|
|
303
|
+
dp.Field(
|
|
304
|
+
label="Instrument",
|
|
305
|
+
open_by_default=True,
|
|
306
|
+
key=None,
|
|
307
|
+
children=[
|
|
308
|
+
dp.Field(key="underlying_instrument", label="Name", width=Unit.PIXEL(250)),
|
|
309
|
+
dp.Field(key="underlying_instrument_isin", label="ISIN", width=Unit.PIXEL(125)),
|
|
310
|
+
dp.Field(key="underlying_instrument_ticker", label="Ticker", width=Unit.PIXEL(100)),
|
|
311
|
+
dp.Field(
|
|
312
|
+
key="underlying_instrument_refinitiv_identifier_code", label="RIC", width=Unit.PIXEL(100)
|
|
313
|
+
),
|
|
314
|
+
],
|
|
315
|
+
),
|
|
304
316
|
dp.Field(
|
|
305
317
|
label="Weight",
|
|
306
318
|
open_by_default=False,
|
|
307
319
|
key=None,
|
|
308
320
|
children=[
|
|
309
|
-
dp.Field(key="effective_weight", label="Effective Weight", show="open"),
|
|
310
|
-
dp.Field(key="target_weight", label="Target Weight", show="open"),
|
|
321
|
+
dp.Field(key="effective_weight", label="Effective Weight", show="open", width=Unit.PIXEL(150)),
|
|
322
|
+
dp.Field(key="target_weight", label="Target Weight", show="open", width=Unit.PIXEL(150)),
|
|
311
323
|
dp.Field(
|
|
312
324
|
key="weighting",
|
|
313
325
|
label="Delta Weight",
|
|
@@ -321,6 +333,7 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
321
333
|
condition=(">", 0),
|
|
322
334
|
),
|
|
323
335
|
],
|
|
336
|
+
width=Unit.PIXEL(150),
|
|
324
337
|
),
|
|
325
338
|
],
|
|
326
339
|
),
|
|
@@ -332,8 +345,8 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
332
345
|
open_by_default=False,
|
|
333
346
|
key=None,
|
|
334
347
|
children=[
|
|
335
|
-
dp.Field(key="effective_shares", label="Effective Shares", show="open"),
|
|
336
|
-
dp.Field(key="target_shares", label="Target Shares", show="open"),
|
|
348
|
+
dp.Field(key="effective_shares", label="Effective Shares", show="open", width=Unit.PIXEL(150)),
|
|
349
|
+
dp.Field(key="target_shares", label="Target Shares", show="open", width=Unit.PIXEL(150)),
|
|
337
350
|
dp.Field(
|
|
338
351
|
key="shares",
|
|
339
352
|
label="Shares",
|
|
@@ -347,6 +360,7 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
347
360
|
condition=(">", 0),
|
|
348
361
|
),
|
|
349
362
|
],
|
|
363
|
+
width=Unit.PIXEL(150),
|
|
350
364
|
),
|
|
351
365
|
],
|
|
352
366
|
)
|
|
@@ -370,16 +384,17 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
370
384
|
condition=("==", Trade.Type.BUY.name),
|
|
371
385
|
),
|
|
372
386
|
],
|
|
387
|
+
width=Unit.PIXEL(125),
|
|
373
388
|
),
|
|
374
|
-
dp.Field(key="comment", label="Comment"),
|
|
375
|
-
dp.Field(key="order", label="Order", show="open"),
|
|
389
|
+
dp.Field(key="comment", label="Comment", width=Unit.PIXEL(250)),
|
|
390
|
+
dp.Field(key="order", label="Order", show="open", width=Unit.PIXEL(100)),
|
|
376
391
|
],
|
|
377
392
|
)
|
|
378
393
|
)
|
|
379
394
|
return dp.ListDisplay(
|
|
380
395
|
fields=fields,
|
|
381
396
|
legends=[TRADE_STATUS_LEGENDS],
|
|
382
|
-
formatting=[
|
|
397
|
+
formatting=[TRADE_STATUS_FORMATTING],
|
|
383
398
|
)
|
|
384
399
|
|
|
385
400
|
def get_instance_display(self) -> Display:
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
|
|
1
3
|
from django.shortcuts import get_object_or_404
|
|
2
4
|
from rest_framework.reverse import reverse
|
|
3
5
|
from wbcore.metadata.configs.endpoints import EndpointViewConfig
|
|
@@ -77,6 +79,13 @@ class TradeTradeProposalEndpointConfig(EndpointViewConfig):
|
|
|
77
79
|
return self.get_list_endpoint()
|
|
78
80
|
return None
|
|
79
81
|
|
|
82
|
+
def get_delete_endpoint(self, **kwargs):
|
|
83
|
+
with suppress(AttributeError, AssertionError):
|
|
84
|
+
trade = self.view.get_object()
|
|
85
|
+
if trade._effective_weight: # we make sure trade with a valid effective position cannot be deleted
|
|
86
|
+
return None
|
|
87
|
+
return super().get_delete_endpoint(**kwargs)
|
|
88
|
+
|
|
80
89
|
|
|
81
90
|
class SubscriptionRedemptionEndpointConfig(TradeEndpointConfig):
|
|
82
91
|
def get_endpoint(self, **kwargs):
|