wbportfolio 1.54.22__py2.py3-none-any.whl → 1.54.23__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/orders/orders.py +10 -2
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +4 -6
- wbportfolio/models/orders/order_proposals.py +53 -20
- wbportfolio/models/orders/orders.py +4 -1
- wbportfolio/models/portfolio.py +1 -0
- wbportfolio/models/products.py +9 -0
- wbportfolio/pms/trading/handler.py +0 -1
- wbportfolio/serializers/orders/order_proposals.py +2 -18
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +61 -15
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +338 -155
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +12 -2
- wbportfolio/viewsets/orders/order_proposals.py +2 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/RECORD +30 -30
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.54.23.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import random
|
|
2
1
|
from decimal import Decimal
|
|
3
2
|
|
|
4
3
|
import factory
|
|
5
4
|
from faker import Faker
|
|
5
|
+
from wbfdm.factories import InstrumentPriceFactory
|
|
6
6
|
|
|
7
7
|
from wbportfolio.models import Order
|
|
8
8
|
|
|
@@ -18,4 +18,12 @@ class OrderFactory(factory.django.DjangoModelFactory):
|
|
|
18
18
|
fees = Decimal(0.0)
|
|
19
19
|
underlying_instrument = factory.SubFactory("wbfdm.factories.InstrumentFactory")
|
|
20
20
|
shares = factory.Faker("pydecimal", min_value=10, max_value=1000, right_digits=4)
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
@factory.post_generation
|
|
23
|
+
def create_price(self, create, extracted, **kwargs):
|
|
24
|
+
if create:
|
|
25
|
+
p = InstrumentPriceFactory.create(
|
|
26
|
+
instrument=self.underlying_instrument, date=self.value_date, calculated=False
|
|
27
|
+
)
|
|
28
|
+
self.price = p.net_value
|
|
29
|
+
self.save()
|
wbportfolio/filters/assets.py
CHANGED
|
@@ -400,8 +400,8 @@ class DistributionFilter(wb_filters.FilterSet):
|
|
|
400
400
|
|
|
401
401
|
group_by = wb_filters.ChoiceFilter(
|
|
402
402
|
label="Group By",
|
|
403
|
-
choices=AssetPositionGroupBy.choices
|
|
404
|
-
initial=AssetPositionGroupBy.INDUSTRY.
|
|
403
|
+
choices=AssetPositionGroupBy.choices,
|
|
404
|
+
initial=AssetPositionGroupBy.INDUSTRY.value,
|
|
405
405
|
method="fake_filter",
|
|
406
406
|
clearable=False,
|
|
407
407
|
required=True,
|
|
@@ -427,6 +427,14 @@ class DistributionFilter(wb_filters.FilterSet):
|
|
|
427
427
|
endpoint=ClassificationGroup.get_representation_endpoint(),
|
|
428
428
|
value_key=ClassificationGroup.get_representation_value_key(),
|
|
429
429
|
label_key=ClassificationGroup.get_representation_label_key(),
|
|
430
|
+
depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
group_by_classification_height = wb_filters.NumberFilter(
|
|
434
|
+
method="fake_filter",
|
|
435
|
+
label="Classification Height",
|
|
436
|
+
initial=0,
|
|
437
|
+
depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
|
|
430
438
|
)
|
|
431
439
|
|
|
432
440
|
class Meta:
|
|
@@ -32,7 +32,7 @@ class OrderImportHandler(ImportExportHandler):
|
|
|
32
32
|
self.order_proposal = OrderProposal.objects.get(id=data.pop("order_proposal_id"))
|
|
33
33
|
weighting = data.get("target_weight", data.get("weighting"))
|
|
34
34
|
shares = data.get("target_shares", data.get("shares", 0))
|
|
35
|
-
if
|
|
35
|
+
if weighting is None:
|
|
36
36
|
raise DeserializationError("We couldn't figure out the target weight column")
|
|
37
37
|
position_dto = Position(
|
|
38
38
|
underlying_instrument=underlying_instrument.id,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-08-04 09:30
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('wbportfolio', '0085_order_desired_target_weight'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='orderproposal',
|
|
16
|
+
name='total_cash_weight',
|
|
17
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0'), help_text='The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.', max_digits=5, verbose_name='Total Cash Weight'),
|
|
18
|
+
),
|
|
19
|
+
]
|
wbportfolio/models/asset.py
CHANGED
|
@@ -30,7 +30,6 @@ from pandas._libs.tslibs.offsets import BDay
|
|
|
30
30
|
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
31
31
|
from wbcore.contrib.io.mixins import ImportMixin
|
|
32
32
|
from wbcore.signals import pre_merge
|
|
33
|
-
from wbcore.utils.enum import ChoiceEnum
|
|
34
33
|
from wbfdm.models import Classification, ClassificationGroup, Instrument
|
|
35
34
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
36
35
|
from wbfdm.signals import add_instrument_to_investable_universe
|
|
@@ -223,25 +222,12 @@ class AnalyticalAssetPositionManager(DefaultAssetPositionManager):
|
|
|
223
222
|
)
|
|
224
223
|
|
|
225
224
|
|
|
226
|
-
class AssetPositionGroupBy(
|
|
227
|
-
INDUSTRY = "Industry"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
LIQUIDITY = "Liquidity"
|
|
233
|
-
|
|
234
|
-
@classmethod
|
|
235
|
-
def get_class_method_group_by(cls, name: str):
|
|
236
|
-
_map = {
|
|
237
|
-
"INDUSTRY": "industry",
|
|
238
|
-
"COUNTRY": AssetPosition.country_group_by,
|
|
239
|
-
"CURRENCY": AssetPosition.currency_group_by,
|
|
240
|
-
"CASH": AssetPosition.cash_group_by,
|
|
241
|
-
"MARKET_CAPITALIZATION": AssetPosition.marketcap_group_by,
|
|
242
|
-
"LIQUIDITY": AssetPosition.liquidity_group_by,
|
|
243
|
-
}
|
|
244
|
-
return _map[name]
|
|
225
|
+
class AssetPositionGroupBy(models.TextChoices):
|
|
226
|
+
INDUSTRY = "classification", "Industry"
|
|
227
|
+
INSTRUMENT_TYPE = "instrument_type", "Type"
|
|
228
|
+
COUNTRY = "country", "Country"
|
|
229
|
+
CURRENCY = "currency", "Currency"
|
|
230
|
+
CASH = "is_cash", "Cash"
|
|
245
231
|
|
|
246
232
|
|
|
247
233
|
class AssetPosition(ImportMixin, models.Model):
|
wbportfolio/models/builder.py
CHANGED
|
@@ -94,7 +94,6 @@ class AssetPositionBuilder:
|
|
|
94
94
|
val_date: date,
|
|
95
95
|
instrument_id: int,
|
|
96
96
|
weighting: float,
|
|
97
|
-
infer_underlying_quote_price: bool = False,
|
|
98
97
|
**kwargs,
|
|
99
98
|
) -> "AssetPosition":
|
|
100
99
|
underlying_quote = self._get_instrument(instrument_id)
|
|
@@ -119,9 +118,6 @@ class AssetPositionBuilder:
|
|
|
119
118
|
)
|
|
120
119
|
parameters.update(kwargs)
|
|
121
120
|
position = AssetPosition(**parameters)
|
|
122
|
-
position.pre_save(
|
|
123
|
-
infer_underlying_quote_price=infer_underlying_quote_price
|
|
124
|
-
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
125
121
|
return position
|
|
126
122
|
|
|
127
123
|
def set_return(self, returns: pd.DataFrame):
|
|
@@ -151,8 +147,10 @@ class AssetPositionBuilder:
|
|
|
151
147
|
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
152
148
|
for position in positions:
|
|
153
149
|
if not isinstance(position, AssetPosition):
|
|
154
|
-
position = self._dict_to_model(*position
|
|
155
|
-
|
|
150
|
+
position = self._dict_to_model(*position)
|
|
151
|
+
position.pre_save(
|
|
152
|
+
infer_underlying_quote_price=infer_underlying_quote_price
|
|
153
|
+
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
156
154
|
# Generate unique composite key
|
|
157
155
|
key = (
|
|
158
156
|
position.underlying_quote.id,
|
|
@@ -81,6 +81,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
81
81
|
min_order_value = models.IntegerField(
|
|
82
82
|
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
83
83
|
)
|
|
84
|
+
total_cash_weight = models.DecimalField(
|
|
85
|
+
default=Decimal("0"),
|
|
86
|
+
decimal_places=4,
|
|
87
|
+
max_digits=5,
|
|
88
|
+
verbose_name="Total Cash Weight",
|
|
89
|
+
help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
90
|
+
)
|
|
84
91
|
|
|
85
92
|
class Meta:
|
|
86
93
|
verbose_name = "Order Proposal"
|
|
@@ -162,6 +169,10 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
162
169
|
currency=self.portfolio.currency, defaults={"is_cash": True, "name": self.portfolio.currency.title}
|
|
163
170
|
)[0]
|
|
164
171
|
|
|
172
|
+
@property
|
|
173
|
+
def total_expected_target_weight(self) -> Decimal:
|
|
174
|
+
return Decimal("1") - self.total_cash_weight
|
|
175
|
+
|
|
165
176
|
def get_orders(self):
|
|
166
177
|
base_qs = self.orders.all().annotate(
|
|
167
178
|
last_effective_date=Subquery(
|
|
@@ -236,7 +247,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
236
247
|
def __str__(self) -> str:
|
|
237
248
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
238
249
|
|
|
239
|
-
def convert_to_portfolio(
|
|
250
|
+
def convert_to_portfolio(
|
|
251
|
+
self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
|
|
252
|
+
) -> PortfolioDTO:
|
|
240
253
|
"""
|
|
241
254
|
Data Transfer Object
|
|
242
255
|
Returns:
|
|
@@ -252,9 +265,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
252
265
|
currency_fx_rate=asset._currency_fx_rate,
|
|
253
266
|
)
|
|
254
267
|
for order in self.get_orders():
|
|
268
|
+
previous_weight = order._previous_weight
|
|
269
|
+
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
270
|
+
delta_weight = order.desired_target_weight - previous_weight
|
|
271
|
+
else:
|
|
272
|
+
delta_weight = order.weighting
|
|
255
273
|
portfolio[order.underlying_instrument] = dict(
|
|
256
|
-
weighting=
|
|
257
|
-
delta_weight=
|
|
274
|
+
weighting=previous_weight,
|
|
275
|
+
delta_weight=delta_weight,
|
|
258
276
|
shares=order._target_shares if not use_effective else order._effective_shares,
|
|
259
277
|
price=order.price,
|
|
260
278
|
currency_fx_rate=order.currency_fx_rate,
|
|
@@ -272,6 +290,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
272
290
|
for instrument, row in portfolio.items():
|
|
273
291
|
weighting = row["weighting"]
|
|
274
292
|
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
293
|
+
|
|
275
294
|
if not use_effective:
|
|
276
295
|
drifted_weight = (
|
|
277
296
|
round(
|
|
@@ -297,7 +316,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
297
316
|
)
|
|
298
317
|
)
|
|
299
318
|
total_weighting += weighting
|
|
300
|
-
if portfolio and with_cash and (cash_weight := Decimal("1") - total_weighting):
|
|
319
|
+
if portfolio and with_cash and total_weighting and (cash_weight := Decimal("1") - total_weighting):
|
|
301
320
|
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
302
321
|
positions.append(cash_position._build_dto())
|
|
303
322
|
return PortfolioDTO(positions)
|
|
@@ -332,7 +351,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
332
351
|
|
|
333
352
|
return order_proposal_clone
|
|
334
353
|
|
|
335
|
-
def normalize_orders(self
|
|
354
|
+
def normalize_orders(self):
|
|
336
355
|
"""
|
|
337
356
|
Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
|
|
338
357
|
The existing order will be modified directly with the given normalization factor
|
|
@@ -341,7 +360,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
341
360
|
self.trade_date,
|
|
342
361
|
effective_portfolio=self._get_default_effective_portfolio(),
|
|
343
362
|
target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
|
|
344
|
-
total_target_weight=
|
|
363
|
+
total_target_weight=self.total_expected_target_weight,
|
|
345
364
|
)
|
|
346
365
|
leftovers_orders = self.orders.all()
|
|
347
366
|
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
@@ -351,14 +370,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
351
370
|
order.save()
|
|
352
371
|
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
353
372
|
leftovers_orders.delete()
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
373
|
+
self.fix_quantization()
|
|
374
|
+
|
|
375
|
+
def fix_quantization(self):
|
|
376
|
+
if self.orders.exists():
|
|
377
|
+
t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
378
|
+
# we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
|
|
379
|
+
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
380
|
+
biggest_order = self.orders.latest("weighting")
|
|
381
|
+
biggest_order.weighting -= quantize_error
|
|
382
|
+
biggest_order.save()
|
|
383
|
+
|
|
384
|
+
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
362
385
|
if self.rebalancing_model:
|
|
363
386
|
params = {}
|
|
364
387
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -367,7 +390,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
367
390
|
return self.rebalancing_model.get_target_portfolio(
|
|
368
391
|
self.portfolio, self.trade_date, self.value_date, **params
|
|
369
392
|
)
|
|
370
|
-
return self.convert_to_portfolio(use_effective=False)
|
|
393
|
+
return self.convert_to_portfolio(use_effective=False, use_desired_target_weight=use_desired_target_weight)
|
|
371
394
|
|
|
372
395
|
def _get_default_effective_portfolio(self):
|
|
373
396
|
return self.convert_to_portfolio(use_effective=True)
|
|
@@ -377,7 +400,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
377
400
|
target_portfolio: PortfolioDTO | None = None,
|
|
378
401
|
effective_portfolio: PortfolioRole | None = None,
|
|
379
402
|
validate_order: bool = True,
|
|
380
|
-
|
|
403
|
+
use_desired_target_weight: bool = False,
|
|
381
404
|
):
|
|
382
405
|
"""
|
|
383
406
|
Will delete all existing orders and recreate them from the method `create_or_update_trades`
|
|
@@ -387,7 +410,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
387
410
|
# delete all existing orders
|
|
388
411
|
# Get effective and target portfolio
|
|
389
412
|
if not target_portfolio:
|
|
390
|
-
target_portfolio = self._get_default_target_portfolio()
|
|
413
|
+
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
391
414
|
if not effective_portfolio:
|
|
392
415
|
effective_portfolio = self._get_default_effective_portfolio()
|
|
393
416
|
if target_portfolio:
|
|
@@ -395,13 +418,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
395
418
|
self.trade_date,
|
|
396
419
|
effective_portfolio=effective_portfolio,
|
|
397
420
|
target_portfolio=target_portfolio,
|
|
398
|
-
total_target_weight=
|
|
421
|
+
total_target_weight=self.total_expected_target_weight,
|
|
399
422
|
)
|
|
400
423
|
if validate_order:
|
|
401
424
|
service.is_valid()
|
|
402
425
|
orders = service.validated_trades
|
|
403
426
|
else:
|
|
404
427
|
orders = service.trades_batch.trades_map.values()
|
|
428
|
+
|
|
405
429
|
for order_dto in orders:
|
|
406
430
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
407
431
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -425,7 +449,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
425
449
|
currency_fx_rate=currency_fx_rate,
|
|
426
450
|
)
|
|
427
451
|
order.price = order.get_price()
|
|
428
|
-
order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
|
|
429
452
|
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
430
453
|
if not order.price:
|
|
431
454
|
order.price = Decimal("0.0")
|
|
@@ -435,6 +458,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
435
458
|
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
436
459
|
self.get_orders().filter(target_weight=0, effective_weight=0).delete()
|
|
437
460
|
|
|
461
|
+
self.fix_quantization()
|
|
462
|
+
|
|
463
|
+
for order in self.get_orders():
|
|
464
|
+
order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
|
|
465
|
+
order.save()
|
|
466
|
+
|
|
438
467
|
def approve_workflow(
|
|
439
468
|
self,
|
|
440
469
|
approve_automatically: bool = True,
|
|
@@ -647,13 +676,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
647
676
|
def submit(self, by=None, description=None, **kwargs):
|
|
648
677
|
orders = []
|
|
649
678
|
orders_validation_warnings = []
|
|
650
|
-
|
|
679
|
+
qs = self.get_orders()
|
|
680
|
+
total_target_weight = Decimal("0")
|
|
681
|
+
for order in qs:
|
|
651
682
|
order_warnings = order.submit(
|
|
652
683
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
653
684
|
)
|
|
654
685
|
if order_warnings:
|
|
655
686
|
orders_validation_warnings.extend(order_warnings)
|
|
656
687
|
orders.append(order)
|
|
688
|
+
total_target_weight += order._target_weight
|
|
657
689
|
|
|
658
690
|
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
659
691
|
|
|
@@ -663,6 +695,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
663
695
|
estimated_cash_position._build_dto()
|
|
664
696
|
)
|
|
665
697
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
698
|
+
self.total_cash_weight = Decimal("1") - total_target_weight
|
|
666
699
|
return orders_validation_warnings
|
|
667
700
|
|
|
668
701
|
def can_submit(self):
|
|
@@ -205,7 +205,7 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
205
205
|
if estimated_shares:
|
|
206
206
|
self.shares = estimated_shares
|
|
207
207
|
if self.id:
|
|
208
|
-
self.
|
|
208
|
+
self.set_type()
|
|
209
209
|
super().save(*args, **kwargs)
|
|
210
210
|
|
|
211
211
|
@classmethod
|
|
@@ -224,6 +224,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
224
224
|
else:
|
|
225
225
|
return Order.Type.SELL
|
|
226
226
|
|
|
227
|
+
def set_type(self):
|
|
228
|
+
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
229
|
+
|
|
227
230
|
def get_price(self) -> Decimal:
|
|
228
231
|
try:
|
|
229
232
|
return self.underlying_instrument.get_price(self.value_date)
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -935,6 +935,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
935
935
|
)
|
|
936
936
|
self.builder.add(
|
|
937
937
|
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
938
|
+
infer_underlying_quote_price=True,
|
|
938
939
|
)
|
|
939
940
|
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
940
941
|
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
wbportfolio/models/products.py
CHANGED
|
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|
|
4
4
|
|
|
5
5
|
from celery import shared_task
|
|
6
6
|
from django.contrib import admin
|
|
7
|
+
from django.contrib.contenttypes.fields import GenericRelation
|
|
7
8
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
|
8
9
|
from django.contrib.postgres.fields import DateRangeField, RangeOperators
|
|
9
10
|
from django.db import models
|
|
@@ -33,6 +34,7 @@ from wbcore.utils.enum import ChoiceEnum
|
|
|
33
34
|
from wbcrm.models.accounts import Account
|
|
34
35
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
35
36
|
from wbfdm.models.instruments.instruments import InstrumentManager, InstrumentType
|
|
37
|
+
from wbreport.models import Report
|
|
36
38
|
|
|
37
39
|
from wbportfolio.models.portfolio_relationship import InstrumentPortfolioThroughModel
|
|
38
40
|
|
|
@@ -198,6 +200,8 @@ class FeeProductPercentage(models.Model):
|
|
|
198
200
|
|
|
199
201
|
|
|
200
202
|
class Product(PMSInstrumentAbstractModel):
|
|
203
|
+
reports = GenericRelation(Report)
|
|
204
|
+
|
|
201
205
|
share_price = models.PositiveIntegerField(
|
|
202
206
|
default=100,
|
|
203
207
|
verbose_name="Share Price",
|
|
@@ -344,6 +348,11 @@ class Product(PMSInstrumentAbstractModel):
|
|
|
344
348
|
|
|
345
349
|
self.is_managed = True
|
|
346
350
|
|
|
351
|
+
def save(self, *args, **kwargs):
|
|
352
|
+
super().save(*args, **kwargs)
|
|
353
|
+
if self.delisted_date and self.delisted_date <= date.today():
|
|
354
|
+
self.reports.update(is_active=False)
|
|
355
|
+
|
|
347
356
|
def get_title(self):
|
|
348
357
|
if self.parent:
|
|
349
358
|
return f"{self.parent.name} ({self.name})"
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from decimal import Decimal
|
|
2
|
-
|
|
3
1
|
from django.contrib.messages import warning
|
|
4
2
|
from django.core.exceptions import ValidationError
|
|
5
3
|
from rest_framework.reverse import reverse
|
|
@@ -26,25 +24,12 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
26
24
|
queryset=Portfolio.objects.all(), write_only=True, required=False
|
|
27
25
|
)
|
|
28
26
|
_target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
|
|
29
|
-
total_cash_weight = wb_serializers.DecimalField(
|
|
30
|
-
default=0,
|
|
31
|
-
decimal_places=4,
|
|
32
|
-
max_digits=5,
|
|
33
|
-
write_only=True,
|
|
34
|
-
required=False,
|
|
35
|
-
precision=4,
|
|
36
|
-
percent=True,
|
|
37
|
-
label="Target Cash",
|
|
38
|
-
help_text="Enter the desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
27
|
trade_date = wb_serializers.DateField(
|
|
42
28
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
43
29
|
)
|
|
44
30
|
|
|
45
31
|
def create(self, validated_data):
|
|
46
32
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
47
|
-
total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
|
|
48
33
|
rebalancing_model = validated_data.get("rebalancing_model", None)
|
|
49
34
|
if request := self.context.get("request"):
|
|
50
35
|
validated_data["creator"] = request.user.profile
|
|
@@ -59,9 +44,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
59
44
|
)
|
|
60
45
|
|
|
61
46
|
try:
|
|
62
|
-
obj.reset_orders(
|
|
63
|
-
target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
|
|
64
|
-
)
|
|
47
|
+
obj.reset_orders(target_portfolio=target_portfolio_dto)
|
|
65
48
|
except ValidationError as e:
|
|
66
49
|
if request := self.context.get("request"):
|
|
67
50
|
warning(request, str(e), extra_tags="auto_close=0")
|
|
@@ -86,6 +69,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
86
69
|
class Meta:
|
|
87
70
|
model = OrderProposal
|
|
88
71
|
only_fsm_transition_on_instance = True
|
|
72
|
+
percent_fields = ["total_cash_weight"]
|
|
89
73
|
fields = (
|
|
90
74
|
"id",
|
|
91
75
|
"trade_date",
|
wbportfolio/tests/conftest.py
CHANGED
|
@@ -70,7 +70,7 @@ from wbportfolio.factories import (
|
|
|
70
70
|
RebalancerFactory,
|
|
71
71
|
RebalancingModelFactory
|
|
72
72
|
)
|
|
73
|
-
|
|
73
|
+
from wbreport.factories import ReportFactory, ReportAssetFactory, ReportVersionFactory, ReportClassFactory, ReportCategoryFactory
|
|
74
74
|
from wbcore.tests.conftest import * # isort:skip
|
|
75
75
|
|
|
76
76
|
register(AccountFactory)
|
|
@@ -147,7 +147,11 @@ register(DailyPortfolioCashFlowFactory)
|
|
|
147
147
|
|
|
148
148
|
register(AccountReconciliationFactory)
|
|
149
149
|
register(AccountReconciliationLineFactory)
|
|
150
|
-
|
|
150
|
+
register(ReportFactory)
|
|
151
|
+
register(ReportAssetFactory)
|
|
152
|
+
register(ReportVersionFactory)
|
|
153
|
+
register(ReportClassFactory)
|
|
154
|
+
register(ReportCategoryFactory)
|
|
151
155
|
|
|
152
156
|
pre_migrate.connect(app_pre_migration, sender=apps.get_app_config("wbportfolio"))
|
|
153
157
|
from .signals import * # noqa: F401
|
|
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|
|
4
4
|
from unittest.mock import call, patch
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
|
+
from django.db.models import Sum
|
|
7
8
|
from faker import Faker
|
|
8
9
|
from pandas._libs.tslibs.offsets import BDay, BusinessMonthEnd
|
|
9
10
|
|
|
@@ -50,31 +51,36 @@ class TestOrderProposal:
|
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
# Create orders for testing
|
|
53
|
-
|
|
54
|
+
o1 = order_factory.create(
|
|
54
55
|
order_proposal=order_proposal,
|
|
55
56
|
weighting=Decimal("0.05"),
|
|
56
57
|
portfolio=order_proposal.portfolio,
|
|
57
58
|
underlying_instrument=a1.underlying_quote,
|
|
58
59
|
)
|
|
59
|
-
|
|
60
|
+
o2 = order_factory.create(
|
|
60
61
|
order_proposal=order_proposal,
|
|
61
62
|
weighting=Decimal("-0.05"),
|
|
62
63
|
portfolio=order_proposal.portfolio,
|
|
63
64
|
underlying_instrument=a2.underlying_quote,
|
|
64
65
|
)
|
|
65
66
|
|
|
67
|
+
r1 = o1.price / a1.initial_price - Decimal("1")
|
|
68
|
+
r2 = o2.price / a2.initial_price - Decimal("1")
|
|
69
|
+
p_return = a1.weighting * (Decimal("1") + r1) + a2.weighting * (Decimal("1") + r2)
|
|
66
70
|
# Get the validated trading service
|
|
67
|
-
|
|
71
|
+
trades = order_proposal.validated_trading_service.trades_batch.trades_map
|
|
72
|
+
t1 = trades[a1.underlying_quote.id]
|
|
73
|
+
t2 = trades[a2.underlying_quote.id]
|
|
68
74
|
|
|
69
75
|
# Assert effective and target portfolios are as expected
|
|
70
|
-
assert
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
a2.
|
|
77
|
-
|
|
76
|
+
assert t1.previous_weight == a1.weighting
|
|
77
|
+
assert t2.previous_weight == a2.weighting
|
|
78
|
+
assert t1.target_weight == pytest.approx(
|
|
79
|
+
a1.weighting * ((r1 + Decimal("1")) / p_return) + o1.weighting, abs=Decimal("1e-8")
|
|
80
|
+
)
|
|
81
|
+
assert t2.target_weight == pytest.approx(
|
|
82
|
+
a2.weighting * ((r2 + Decimal("1")) / p_return) + o2.weighting, abs=Decimal("1e-8")
|
|
83
|
+
)
|
|
78
84
|
|
|
79
85
|
# Test the calculation of the last effective date
|
|
80
86
|
def test_last_effective_date(self, order_proposal, asset_position_factory):
|
|
@@ -361,15 +367,12 @@ class TestOrderProposal:
|
|
|
361
367
|
assert order_proposal.orders.get(underlying_instrument=i2).weighting == Decimal("-0.3")
|
|
362
368
|
assert order_proposal.orders.get(underlying_instrument=cash).weighting == Decimal("0.5")
|
|
363
369
|
|
|
364
|
-
def test_reset_orders_remove_invalid_orders(self, order_proposal, order_factory
|
|
370
|
+
def test_reset_orders_remove_invalid_orders(self, order_proposal, order_factory):
|
|
365
371
|
# create a invalid trade and its price
|
|
366
372
|
invalid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(0))
|
|
367
|
-
instrument_price_factory.create(date=invalid_trade.value_date, instrument=invalid_trade.underlying_instrument)
|
|
368
373
|
|
|
369
374
|
# create a valid trade and its price
|
|
370
375
|
valid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(1))
|
|
371
|
-
instrument_price_factory.create(date=valid_trade.value_date, instrument=valid_trade.underlying_instrument)
|
|
372
|
-
|
|
373
376
|
order_proposal.reset_orders()
|
|
374
377
|
assert order_proposal.orders.get(underlying_instrument=valid_trade.underlying_instrument).weighting == Decimal(
|
|
375
378
|
"1"
|
|
@@ -687,3 +690,46 @@ class TestOrderProposal:
|
|
|
687
690
|
order.save()
|
|
688
691
|
assert order.shares == Decimal(0)
|
|
689
692
|
assert order.weighting == Decimal(0)
|
|
693
|
+
|
|
694
|
+
def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
|
|
695
|
+
order1 = order_factory.create(
|
|
696
|
+
order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")
|
|
697
|
+
)
|
|
698
|
+
order2 = order_factory.create(
|
|
699
|
+
order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.3")
|
|
700
|
+
)
|
|
701
|
+
order_proposal.submit()
|
|
702
|
+
order_proposal.approve()
|
|
703
|
+
order_proposal.save()
|
|
704
|
+
|
|
705
|
+
order1.refresh_from_db()
|
|
706
|
+
order2.refresh_from_db()
|
|
707
|
+
assert order1.desired_target_weight == Decimal("0.5")
|
|
708
|
+
assert order2.desired_target_weight == Decimal("0.5")
|
|
709
|
+
assert order1.weighting == Decimal("0.5")
|
|
710
|
+
assert order2.weighting == Decimal("0.5")
|
|
711
|
+
|
|
712
|
+
order1.desired_target_weight = Decimal("0.7")
|
|
713
|
+
order2.desired_target_weight = Decimal("0.3")
|
|
714
|
+
order1.save()
|
|
715
|
+
order2.save()
|
|
716
|
+
|
|
717
|
+
order_proposal.reset_orders(use_desired_target_weight=True)
|
|
718
|
+
order1.refresh_from_db()
|
|
719
|
+
order2.refresh_from_db()
|
|
720
|
+
assert order1.weighting == Decimal("0.7")
|
|
721
|
+
assert order2.weighting == Decimal("0.3")
|
|
722
|
+
|
|
723
|
+
def test_reset_order_proposal_keeps_target_cash_weight(self, order_factory, order_proposal_factory):
|
|
724
|
+
order_proposal = order_proposal_factory.create(
|
|
725
|
+
total_cash_weight=Decimal("0.02")
|
|
726
|
+
) # create a OP with total cash weight of 2%
|
|
727
|
+
|
|
728
|
+
# create orders that total weight account for only 50%
|
|
729
|
+
order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
|
|
730
|
+
order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.2"))
|
|
731
|
+
|
|
732
|
+
order_proposal.reset_orders()
|
|
733
|
+
assert order_proposal.get_orders().aggregate(s=Sum("target_weight"))["s"] == Decimal(
|
|
734
|
+
"0.98"
|
|
735
|
+
), "The total target weight leftover does not equal the stored total cash weight"
|
|
@@ -215,3 +215,14 @@ class TestProductModel(PortfolioTestMixin):
|
|
|
215
215
|
body=f"The product {product} will be terminated on the {product.delisted_date:%Y-%m-%d}",
|
|
216
216
|
user=internal_user,
|
|
217
217
|
)
|
|
218
|
+
|
|
219
|
+
def test_delist_product_disable_report(self, product, report_factory):
|
|
220
|
+
report = report_factory.create(content_object=product, is_active=True)
|
|
221
|
+
assert product.delisted_date is None
|
|
222
|
+
assert report.is_active
|
|
223
|
+
|
|
224
|
+
product.delisted_date = datetime.date.today()
|
|
225
|
+
product.save()
|
|
226
|
+
|
|
227
|
+
report.refresh_from_db()
|
|
228
|
+
assert report.is_active is False
|
wbportfolio/tests/signals.py
CHANGED
|
@@ -17,8 +17,6 @@ from wbportfolio.viewsets import (
|
|
|
17
17
|
ClaimEntryModelViewSet,
|
|
18
18
|
CustodianDistributionInstrumentChartViewSet,
|
|
19
19
|
CustomerDistributionInstrumentChartViewSet,
|
|
20
|
-
DistributionChartViewSet,
|
|
21
|
-
DistributionTableViewSet,
|
|
22
20
|
NominalProductChartView,
|
|
23
21
|
OrderOrderProposalModelViewSet,
|
|
24
22
|
OrderProposalPortfolioModelViewSet,
|
|
@@ -96,14 +94,6 @@ def receive_kwargs_portfolio_role_instrument(sender, *args, **kwargs):
|
|
|
96
94
|
return {}
|
|
97
95
|
|
|
98
96
|
|
|
99
|
-
@receiver(custom_update_kwargs, sender=DistributionChartViewSet)
|
|
100
|
-
@receiver(custom_update_kwargs, sender=DistributionTableViewSet)
|
|
101
|
-
def receive_kwargs_distribution_chart_instrument_id(sender, *args, **kwargs):
|
|
102
|
-
if instrument_id := kwargs.get("underlying_instrument_id"):
|
|
103
|
-
return {"instrument_id": instrument_id}
|
|
104
|
-
return {}
|
|
105
|
-
|
|
106
|
-
|
|
107
97
|
@receiver(custom_update_kwargs, sender=ProductPerformanceFeesModelViewSet)
|
|
108
98
|
def receive_kwargs_product_performance_fees(sender, *args, **kwargs):
|
|
109
99
|
CurrencyFXRatesFactory()
|