wbportfolio 1.54.23__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 (62) 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/portfolios.py +1 -1
  9. wbportfolio/factories/rebalancing.py +1 -1
  10. wbportfolio/filters/orders/__init__.py +1 -0
  11. wbportfolio/filters/orders/order_proposals.py +58 -0
  12. wbportfolio/filters/portfolios.py +20 -0
  13. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  14. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  15. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  16. wbportfolio/import_export/backends/utils.py +0 -17
  17. wbportfolio/import_export/handlers/asset_position.py +1 -1
  18. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  19. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  20. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  21. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  22. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  23. wbportfolio/models/builder.py +70 -25
  24. wbportfolio/models/mixins/instruments.py +7 -0
  25. wbportfolio/models/orders/order_proposals.py +510 -161
  26. wbportfolio/models/orders/orders.py +20 -10
  27. wbportfolio/models/orders/routing.py +54 -0
  28. wbportfolio/models/portfolio.py +76 -41
  29. wbportfolio/models/rebalancing.py +6 -6
  30. wbportfolio/models/transactions/transactions.py +10 -6
  31. wbportfolio/order_routing/__init__.py +19 -0
  32. wbportfolio/order_routing/adapters/__init__.py +57 -0
  33. wbportfolio/order_routing/adapters/ubs.py +161 -0
  34. wbportfolio/pms/trading/handler.py +4 -0
  35. wbportfolio/pms/typing.py +62 -8
  36. wbportfolio/rebalancing/models/composite.py +1 -1
  37. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  38. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  39. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  40. wbportfolio/serializers/orders/order_proposals.py +23 -3
  41. wbportfolio/serializers/orders/orders.py +5 -2
  42. wbportfolio/serializers/positions.py +2 -2
  43. wbportfolio/serializers/rebalancing.py +1 -1
  44. wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
  45. wbportfolio/tests/models/test_imports.py +5 -3
  46. wbportfolio/tests/models/test_portfolios.py +57 -23
  47. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  48. wbportfolio/tests/rebalancing/test_models.py +3 -5
  49. wbportfolio/viewsets/charts/assets.py +4 -1
  50. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  51. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  52. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  53. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
  54. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  55. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  56. wbportfolio/viewsets/orders/order_proposals.py +45 -6
  57. wbportfolio/viewsets/orders/orders.py +31 -29
  58. wbportfolio/viewsets/portfolios.py +3 -3
  59. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
  60. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
  61. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  62. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -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:
@@ -103,7 +125,7 @@ class AssetPositionBuilder:
103
125
 
104
126
  parameters = dict(
105
127
  underlying_quote=underlying_quote,
106
- weighting=round(weighting, 8),
128
+ weighting=round(weighting, MINIMUM_DECIMAL),
107
129
  date=val_date,
108
130
  asset_valuation_date=val_date,
109
131
  is_estimated=True,
@@ -120,16 +142,36 @@ class AssetPositionBuilder:
120
142
  position = AssetPosition(**parameters)
121
143
  return position
122
144
 
123
- def set_return(self, returns: pd.DataFrame):
124
- self.returns = returns
125
-
126
- def set_prices(self, prices: dict[date, dict[int, float]] | None = None):
127
- self.prices = prices
128
-
129
- def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date, use_dl: bool = True):
130
- self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
131
- from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
132
- )
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)
133
175
 
134
176
  def add(
135
177
  self,
@@ -163,6 +205,9 @@ class AssetPositionBuilder:
163
205
  position.initial_shares += existing_position.initial_shares
164
206
  # ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
165
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
166
211
  if position.initial_price is not None and position.initial_currency_fx_rate is not None:
167
212
  self._positions[position.date][key] = position
168
213
  return self
@@ -246,7 +291,7 @@ class AssetPositionBuilder:
246
291
  tasks = chain(
247
292
  *[
248
293
  trigger_portfolio_change_as_task.si(
249
- self.portfolio.id, d, changed_portfolio=portfolio, **task_kwargs
294
+ self.portfolio.id, d, changed_portfolio=portfolio, evaluate_rebalancer=False, **task_kwargs
250
295
  )
251
296
  for d, portfolio in self._change_at_date_tasks.items()
252
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,