wbportfolio 1.54.21__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.

Files changed (34) hide show
  1. wbportfolio/constants.py +1 -0
  2. wbportfolio/factories/orders/orders.py +10 -2
  3. wbportfolio/filters/assets.py +10 -2
  4. wbportfolio/import_export/handlers/orders.py +1 -1
  5. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  6. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  7. wbportfolio/models/asset.py +6 -20
  8. wbportfolio/models/builder.py +11 -11
  9. wbportfolio/models/orders/order_proposals.py +53 -20
  10. wbportfolio/models/orders/orders.py +4 -1
  11. wbportfolio/models/portfolio.py +16 -8
  12. wbportfolio/models/products.py +9 -0
  13. wbportfolio/pms/trading/handler.py +0 -1
  14. wbportfolio/serializers/orders/order_proposals.py +2 -18
  15. wbportfolio/tests/conftest.py +6 -2
  16. wbportfolio/tests/models/orders/test_order_proposals.py +61 -15
  17. wbportfolio/tests/models/test_products.py +11 -0
  18. wbportfolio/tests/signals.py +0 -10
  19. wbportfolio/tests/tests.py +2 -0
  20. wbportfolio/viewsets/__init__.py +7 -4
  21. wbportfolio/viewsets/assets.py +1 -215
  22. wbportfolio/viewsets/charts/__init__.py +6 -1
  23. wbportfolio/viewsets/charts/assets.py +337 -154
  24. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  25. wbportfolio/viewsets/configs/display/assets.py +6 -19
  26. wbportfolio/viewsets/configs/display/products.py +1 -1
  27. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +12 -2
  28. wbportfolio/viewsets/orders/order_proposals.py +2 -1
  29. wbportfolio/viewsets/positions.py +3 -2
  30. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/METADATA +3 -1
  31. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/RECORD +33 -32
  32. wbportfolio/viewsets/signals.py +0 -43
  33. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/WHEEL +0 -0
  34. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1 @@
1
+ EQUITY_TYPE_KEYS = ["american_depository_receipt", "equity"] # TODO might want to move this into a dynamic preference
@@ -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
- price = factory.LazyAttribute(lambda o: random.randint(10, 10000))
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()
@@ -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.name,
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 not weighting:
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,
@@ -10,7 +10,10 @@ def parse(import_source):
10
10
  with suppress(KeyError):
11
11
  series_data = json.loads(import_source.file.read())["payload"]["series"]
12
12
  for series in series_data:
13
- isin = series["item"]["priceIdentifier"]
13
+ try:
14
+ isin = series["key"]["priceIdentifier"]
15
+ except KeyError:
16
+ isin = series["priceIdentifier"]
14
17
  if Product.objects.filter(isin=isin).exists(): # ensure the timeseries contain data for products we handle
15
18
  for point in series["points"]:
16
19
  data.append(
@@ -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
+ ]
@@ -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(ChoiceEnum):
227
- INDUSTRY = "Industry"
228
- COUNTRY = "Country"
229
- CURRENCY = "Currency"
230
- CASH = "Cash"
231
- MARKET_CAPITALIZATION = "Market Cap"
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):
@@ -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):
@@ -130,9 +126,9 @@ class AssetPositionBuilder:
130
126
  def set_prices(self, prices: dict[date, dict[int, float]] | None = None):
131
127
  self.prices = prices
132
128
 
133
- def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date):
129
+ def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date, use_dl: bool = True):
134
130
  self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
135
- from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=True
131
+ from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
136
132
  )
137
133
 
138
134
  def add(
@@ -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, infer_underlying_quote_price=infer_underlying_quote_price)
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,
@@ -221,9 +219,11 @@ class AssetPositionBuilder:
221
219
 
222
220
  for val_date in self.get_dates():
223
221
  if self.portfolio.is_tracked:
224
- with suppress(KeyError):
222
+ try:
225
223
  changed_portfolio = self._get_portfolio(val_date)
226
- self._change_at_date_tasks[val_date] = changed_portfolio
224
+ except KeyError:
225
+ changed_portfolio = None
226
+ self._change_at_date_tasks[val_date] = changed_portfolio
227
227
  self._compute_metrics_tasks.add(val_date)
228
228
  self._positions = defaultdict(dict)
229
229
 
@@ -237,7 +237,7 @@ class AssetPositionBuilder:
237
237
  for d in self._compute_metrics_tasks
238
238
  ]
239
239
  ).apply_async()
240
- self._change_at_date_tasks = set()
240
+ self._change_at_date_tasks = dict()
241
241
 
242
242
  def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
243
243
  from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
@@ -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(self, use_effective: bool = False, with_cash: bool = True) -> PortfolioDTO:
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=order._previous_weight,
257
- delta_weight=order.weighting,
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, total_target_weight: Decimal = Decimal("1.0")):
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=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
- t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
355
- # 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
356
- if quantize_error := (t_weight - total_target_weight):
357
- biggest_order = self.orders.latest("weighting")
358
- biggest_order.weighting -= quantize_error
359
- biggest_order.save()
360
-
361
- def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
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
- total_target_weight: Decimal = Decimal("1.0"),
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=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
- for order in self.get_orders():
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.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
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)
@@ -38,6 +38,7 @@ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
38
38
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
39
39
  from wbportfolio.pms.typing import Position as PositionDTO
40
40
 
41
+ from ..constants import EQUITY_TYPE_KEYS
41
42
  from . import ProductGroup
42
43
 
43
44
  logger = logging.getLogger("pms")
@@ -337,8 +338,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
337
338
  weights = self.get_weights(val_date)
338
339
  return_date = (val_date + BDay(1)).date()
339
340
  returns = self.builder.returns
340
- if pd.Timestamp(return_date) not in returns.index or not set(weights.keys()).issubset(set(returns.columns)):
341
- returns = self.load_builder_returns(val_date, return_date)
341
+ if (
342
+ return_date <= date.today()
343
+ and pd.Timestamp(return_date) not in returns.index
344
+ or not set(weights.keys()).issubset(set(returns.columns))
345
+ ):
346
+ returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl)
342
347
  if pd.Timestamp(return_date) not in returns.index:
343
348
  raise ValueError()
344
349
  returns = returns.loc[:return_date, :]
@@ -507,7 +512,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
507
512
  val_date=val_date,
508
513
  exclude_cash=True,
509
514
  exclude_index=True,
510
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
515
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
511
516
  **kwargs,
512
517
  )
513
518
  if not df.empty:
@@ -520,7 +525,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
520
525
  val_date=val_date,
521
526
  exclude_cash=True,
522
527
  exclude_index=True,
523
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
528
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
524
529
  **kwargs,
525
530
  )
526
531
  if not df.empty:
@@ -592,7 +597,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
592
597
  ) -> pd.DataFrame:
593
598
  qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
594
599
  if only_equity:
595
- qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
600
+ qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
596
601
  qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
597
602
  df = Portfolio.get_contribution_df(
598
603
  qs.select_related("underlying_instrument").values_list(
@@ -725,7 +730,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
725
730
  effective_portfolio_date = (val_date - BDay(1)).date()
726
731
  with suppress(ValueError):
727
732
  if not analytic_portfolio:
728
- analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date)
733
+ analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
729
734
  for instrument in self.pms_instruments:
730
735
  # we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
731
736
  last_price = instrument.get_latest_price(effective_portfolio_date)
@@ -839,11 +844,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
839
844
  self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
840
845
  self.builder.schedule_metric_computation()
841
846
 
842
- def load_builder_returns(self, from_date: date, to_date: date) -> pd.DataFrame:
847
+ def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
843
848
  instruments_ids = list(self.get_weights(from_date).keys())
844
849
  for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
845
850
  instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
846
- self.builder.load_returns(set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date())
851
+ self.builder.load_returns(
852
+ set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
853
+ )
847
854
  return self.builder.returns
848
855
 
849
856
  def get_lookthrough_positions(
@@ -928,6 +935,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
928
935
  )
929
936
  self.builder.add(
930
937
  list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
938
+ infer_underlying_quote_price=True,
931
939
  )
932
940
  self.builder.bulk_create_positions(delete_leftovers=True)
933
941
  self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
@@ -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})"
@@ -87,7 +87,6 @@ class TradingService:
87
87
  total_target_weight
88
88
  )
89
89
  self._effective_portfolio = effective_portfolio
90
- self._target_portfolio = target_portfolio
91
90
 
92
91
  @property
93
92
  def errors(self) -> list[str]:
@@ -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",
@@ -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