wbportfolio 1.54.22__py2.py3-none-any.whl → 1.55.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.

Files changed (79) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,94 @@
1
+ # Generated by Django 5.0.14 on 2025-08-11 14:01
2
+
3
+ import django.db.models.deletion
4
+ import django_fsm
5
+ from django.db import migrations, models
6
+
7
+ def migrate_approve_to_apply(apps, schema_editor):
8
+ OrderProposal = apps.get_model("wbportfolio", "OrderProposal")
9
+ OrderProposal.objects.filter(status="APPROVED").update(status="APPLIED")
10
+
11
+
12
+ class Migration(migrations.Migration):
13
+
14
+ dependencies = [
15
+ ('wbportfolio', '0086_orderproposal_total_cash_weight'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.AddField(
20
+ model_name='product',
21
+ name='order_routing_custodian_adapter',
22
+ field=models.CharField(blank=True, help_text='The dotted path to the order routing custodian adapter', max_length=1024, null=True, verbose_name='Order Routing Custodian Adapter'),
23
+ ),
24
+ migrations.AddField(
25
+ model_name='order',
26
+ name='execution_comment',
27
+ field=models.TextField(blank=True, default='', verbose_name='Execution Comment'),
28
+ ),
29
+ migrations.AddField(
30
+ model_name='order',
31
+ name='execution_confirmed',
32
+ field=models.BooleanField(default=False, verbose_name='Execution Confirmed'),
33
+ ),
34
+ migrations.AddField(
35
+ model_name='orderproposal',
36
+ name='approver',
37
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
38
+ related_name='approver_order_proposals', to='directory.person',
39
+ verbose_name='Approver'),
40
+ ),
41
+ migrations.AddField(
42
+ model_name='orderproposal',
43
+ name='execution_comment',
44
+ field=models.CharField(blank=True, default='', verbose_name='Execution Comment'),
45
+ ),
46
+ migrations.AddField(
47
+ model_name='orderproposal',
48
+ name='execution_status',
49
+ field=models.CharField(blank=True, choices=[('PENDING', 'Pending'), ('IN_DRAFT', 'In Draft'),
50
+ ('COMPLETED', 'Completed'), ('CANCELLED', 'Cancelled'),
51
+ ('REJECTED', 'Rejected'), ('FAILED', 'Failed'),
52
+ ('UNKNOWN', 'Unknown')], default='',
53
+ verbose_name='Execution Status'),
54
+ ),
55
+ migrations.AddField(
56
+ model_name='orderproposal',
57
+ name='execution_status_detail',
58
+ field=models.CharField(blank=True, default='', verbose_name='Execution Status Detail'),
59
+ ),
60
+ migrations.AlterField(
61
+ model_name='orderproposal',
62
+ name='status',
63
+ field=django_fsm.FSMField(choices=[('DRAFT', 'Draft'), ('SUBMIT', 'Pending'), ('APPROVED', 'Approved'),
64
+ ('PENDING_EXECUTION', 'Pending Execution'), ('EXECUTED', 'Executed'),
65
+ ('DENIED', 'Denied'), ('FAILED', 'Failed')], default='DRAFT',
66
+ max_length=50, verbose_name='Status'),
67
+ ),
68
+ migrations.AddField(
69
+ model_name='index',
70
+ name='order_routing_custodian_adapter',
71
+ field=models.CharField(blank=True, help_text='The dotted path to the order routing custodian adapter',
72
+ max_length=1024, null=True, verbose_name='Order Routing Custodian Adapter'),
73
+ ),
74
+ migrations.AddField(
75
+ model_name='productgroup',
76
+ name='order_routing_custodian_adapter',
77
+ field=models.CharField(blank=True, help_text='The dotted path to the order routing custodian adapter',
78
+ max_length=1024, null=True, verbose_name='Order Routing Custodian Adapter'),
79
+ ),
80
+ migrations.RenameField(
81
+ model_name='rebalancer',
82
+ old_name='approve_order_proposal_automatically',
83
+ new_name='apply_order_proposal_automatically',
84
+ ),
85
+ migrations.RunPython(migrate_approve_to_apply),
86
+ migrations.AlterField(
87
+ model_name='orderproposal',
88
+ name='status',
89
+ field=django_fsm.FSMField(
90
+ choices=[('DRAFT', 'Draft'), ('PENDING', 'Pending'), ('APPROVED', 'Approved'), ('DENIED', 'Denied'),
91
+ ('APPLIED', 'Applied'), ('EXECUTION', 'Execution'), ('FAILED', 'Failed')], default='DRAFT',
92
+ max_length=50, verbose_name='Status'),
93
+ ),
94
+ ]
@@ -0,0 +1,19 @@
1
+ # Generated by Django 5.0.14 on 2025-08-18 08:34
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', '0087_product_order_routing_custodian_adapter'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='orderproposal',
16
+ name='total_effective_portfolio_contribution',
17
+ field=models.DecimalField(decimal_places=16, default=Decimal('1'), max_digits=19),
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):
@@ -1,8 +1,9 @@
1
1
  import logging
2
+ import math
2
3
  from collections import defaultdict
3
- from contextlib import suppress
4
4
  from datetime import date
5
- from typing import TYPE_CHECKING
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING, Iterable
6
7
 
7
8
  import pandas as pd
8
9
  from celery import chain, group
@@ -20,6 +21,10 @@ if TYPE_CHECKING:
20
21
  logger = logging.getLogger("pms")
21
22
 
22
23
 
24
+ MINIMUM_DECIMAL = 8
25
+ MIN_STEP = Decimal("0.00000001")
26
+
27
+
23
28
  class AssetPositionBuilder:
24
29
  """
25
30
  Efficiently converts position data into AssetPosition models with batch operations
@@ -34,7 +39,6 @@ class AssetPositionBuilder:
34
39
 
35
40
  _positions: dict[date, dict[tuple[int, int | None], "AssetPosition"]]
36
41
 
37
- _prices: dict[date, dict[int, float]]
38
42
  _fx_rates: dict[date, dict[Currency, CurrencyFXRates]]
39
43
  _instruments: dict[int, Instrument]
40
44
 
@@ -52,13 +56,24 @@ class AssetPositionBuilder:
52
56
  self._change_at_date_tasks = dict()
53
57
  self._positions = defaultdict(dict)
54
58
 
55
- def get_positions(self, **kwargs):
59
+ def get_positions(self, fix_quantization: bool = True, **kwargs):
56
60
  # return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
57
- for _, positions in self._positions.items():
58
- for _, position in positions.items():
59
- if not position.portfolio.is_manageable or position.weighting:
61
+ for positions in self._positions.values():
62
+ quantization_weight_error = round(
63
+ Decimal("1") - sum(map(lambda o: o.weighting, positions.values()))
64
+ if fix_quantization
65
+ else Decimal("0"),
66
+ MINIMUM_DECIMAL,
67
+ )
68
+ for position in sorted(positions.values(), key=lambda x: x.weighting, reverse=True):
69
+ if position.weighting:
60
70
  for k, v in kwargs.items():
61
71
  setattr(position, k, v)
72
+ # if the total weight is not 100%, we add the quantization leftover to some random position (max 1e-8 per position, thus it is negligible)
73
+ if quantization_weight_error:
74
+ step = round(Decimal(math.copysign(MIN_STEP, quantization_weight_error)), MINIMUM_DECIMAL)
75
+ position.weighting += step
76
+ quantization_weight_error -= step
62
77
  yield position
63
78
 
64
79
  def __bool__(self) -> bool:
@@ -76,12 +91,19 @@ class AssetPositionBuilder:
76
91
  try:
77
92
  return self._fx_rates[val_date][currency]
78
93
  except KeyError:
79
- with suppress(CurrencyFXRates.DoesNotExist):
80
- fx_rate = CurrencyFXRates.objects.get(
81
- currency=currency, date=val_date
82
- ) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
83
- self._fx_rates[val_date][currency] = fx_rate
84
- return fx_rate
94
+ if currency.key == "USD":
95
+ fx_rate = CurrencyFXRates.objects.get_or_create(
96
+ currency=currency, date=val_date, defaults={"value": Decimal("1")}
97
+ )[0]
98
+ else:
99
+ try:
100
+ fx_rate = CurrencyFXRates.objects.get(
101
+ currency=currency, date=val_date
102
+ ) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
103
+ except CurrencyFXRates.DoesNotExist:
104
+ fx_rate = CurrencyFXRates.objects.filter(currency=currency, date__lt=val_date).latest("date")
105
+ self._fx_rates[val_date][currency] = fx_rate
106
+ return fx_rate
85
107
 
86
108
  def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
87
109
  try:
@@ -94,7 +116,6 @@ class AssetPositionBuilder:
94
116
  val_date: date,
95
117
  instrument_id: int,
96
118
  weighting: float,
97
- infer_underlying_quote_price: bool = False,
98
119
  **kwargs,
99
120
  ) -> "AssetPosition":
100
121
  underlying_quote = self._get_instrument(instrument_id)
@@ -104,7 +125,7 @@ class AssetPositionBuilder:
104
125
 
105
126
  parameters = dict(
106
127
  underlying_quote=underlying_quote,
107
- weighting=round(weighting, 8),
128
+ weighting=round(weighting, MINIMUM_DECIMAL),
108
129
  date=val_date,
109
130
  asset_valuation_date=val_date,
110
131
  is_estimated=True,
@@ -119,21 +140,38 @@ class AssetPositionBuilder:
119
140
  )
120
141
  parameters.update(kwargs)
121
142
  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
143
  return position
126
144
 
127
- def set_return(self, returns: pd.DataFrame):
128
- self.returns = returns
129
-
130
- def set_prices(self, prices: dict[date, dict[int, float]] | None = None):
131
- self.prices = prices
132
-
133
- def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date, use_dl: bool = True):
134
- 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=use_dl
136
- )
145
+ def load_returns(self, instrument_ids: Iterable[int], from_date: date, to_date: date, use_dl: bool = True):
146
+ if self.returns.empty:
147
+ self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
148
+ from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
149
+ )
150
+ else:
151
+ min_date = min(self.prices.keys())
152
+ max_date = max(self.prices.keys())
153
+ if from_date < min_date or to_date > max_date:
154
+ # we need to refetch everything as we are missing index
155
+ self.prices, self.returns = Instrument.objects.filter(
156
+ id__in=set(instrument_ids).union(set(self.returns.columns))
157
+ ).get_returns_df(
158
+ from_date=min(from_date, min_date),
159
+ to_date=max(to_date, max_date),
160
+ to_currency=self.portfolio.currency,
161
+ use_dl=use_dl,
162
+ )
163
+ else:
164
+ instruments = set(instrument_ids) - set(self.returns.columns)
165
+ if instruments:
166
+ new_prices, new_returns = Instrument.objects.filter(id__in=instruments).get_returns_df(
167
+ from_date=min(from_date, min_date),
168
+ to_date=max(to_date, max_date),
169
+ to_currency=self.portfolio.currency,
170
+ use_dl=use_dl,
171
+ )
172
+ self.returns = self.returns.join(new_returns, how="left").fillna(0)
173
+ for d, p in new_prices.items():
174
+ self.prices[d].update(p)
137
175
 
138
176
  def add(
139
177
  self,
@@ -151,8 +189,10 @@ class AssetPositionBuilder:
151
189
  positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
152
190
  for position in positions:
153
191
  if not isinstance(position, AssetPosition):
154
- position = self._dict_to_model(*position, infer_underlying_quote_price=infer_underlying_quote_price)
155
-
192
+ position = self._dict_to_model(*position)
193
+ position.pre_save(
194
+ infer_underlying_quote_price=infer_underlying_quote_price
195
+ ) # 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
196
  # Generate unique composite key
157
197
  key = (
158
198
  position.underlying_quote.id,
@@ -165,6 +205,9 @@ class AssetPositionBuilder:
165
205
  position.initial_shares += existing_position.initial_shares
166
206
  # ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
167
207
  position.portfolio = self.portfolio
208
+ position.weighting = Decimal(
209
+ round(position.weighting, 8)
210
+ ) # set the weight as it will be saved in the db to handle quantization error accordingly
168
211
  if position.initial_price is not None and position.initial_currency_fx_rate is not None:
169
212
  self._positions[position.date][key] = position
170
213
  return self
@@ -248,7 +291,7 @@ class AssetPositionBuilder:
248
291
  tasks = chain(
249
292
  *[
250
293
  trigger_portfolio_change_as_task.si(
251
- self.portfolio.id, d, changed_portfolio=portfolio, **task_kwargs
294
+ self.portfolio.id, d, changed_portfolio=portfolio, evaluate_rebalancer=False, **task_kwargs
252
295
  )
253
296
  for d, portfolio in self._change_at_date_tasks.items()
254
297
  ]
@@ -150,6 +150,13 @@ class PMSInstrumentAbstractModel(PMSInstrument):
150
150
  default="wbportfolio.models.portfolio.default_estimate_net_value",
151
151
  verbose_name="NAV Computation Method",
152
152
  )
153
+ order_routing_custodian_adapter = models.CharField(
154
+ blank=True,
155
+ null=True,
156
+ max_length=1024,
157
+ verbose_name="Order Routing Custodian Adapter",
158
+ help_text="The dotted path to the order routing custodian adapter",
159
+ )
153
160
  risk_scale = models.IntegerField(
154
161
  validators=[MinValueValidator(1), MaxValueValidator(7)],
155
162
  default=4,