wbportfolio 1.55.8__py2.py3-none-any.whl → 1.59.4__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 (128) hide show
  1. wbportfolio/admin/orders/order_proposals.py +2 -0
  2. wbportfolio/admin/orders/orders.py +2 -0
  3. wbportfolio/admin/portfolio.py +11 -5
  4. wbportfolio/api_clients/ubs.py +23 -11
  5. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  6. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  7. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  8. wbportfolio/contrib/company_portfolio/models.py +69 -39
  9. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  10. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  11. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  12. wbportfolio/factories/assets.py +1 -1
  13. wbportfolio/factories/orders/order_proposals.py +3 -1
  14. wbportfolio/factories/orders/orders.py +8 -3
  15. wbportfolio/factories/product_groups.py +3 -3
  16. wbportfolio/factories/products.py +3 -3
  17. wbportfolio/filters/assets.py +0 -1
  18. wbportfolio/filters/orders/order_proposals.py +3 -6
  19. wbportfolio/filters/portfolios.py +18 -1
  20. wbportfolio/filters/positions.py +0 -1
  21. wbportfolio/filters/transactions/fees.py +0 -2
  22. wbportfolio/filters/transactions/trades.py +0 -1
  23. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  24. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  25. wbportfolio/import_export/handlers/asset_position.py +9 -5
  26. wbportfolio/import_export/handlers/dividend.py +1 -1
  27. wbportfolio/import_export/handlers/fees.py +2 -2
  28. wbportfolio/import_export/handlers/trade.py +4 -4
  29. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  30. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  31. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  32. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  33. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  34. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  35. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  36. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  37. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  38. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  39. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  40. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  41. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  42. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  43. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  44. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  45. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  46. wbportfolio/import_export/resources/trades.py +1 -1
  47. wbportfolio/import_export/utils.py +3 -1
  48. wbportfolio/metric/backends/base.py +2 -2
  49. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  50. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  51. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  52. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  53. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  54. wbportfolio/models/adjustments.py +1 -1
  55. wbportfolio/models/asset.py +7 -3
  56. wbportfolio/models/builder.py +25 -5
  57. wbportfolio/models/custodians.py +3 -3
  58. wbportfolio/models/exceptions.py +1 -1
  59. wbportfolio/models/graphs/portfolio.py +1 -1
  60. wbportfolio/models/graphs/utils.py +11 -11
  61. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  62. wbportfolio/models/orders/order_proposals.py +620 -490
  63. wbportfolio/models/orders/orders.py +237 -75
  64. wbportfolio/models/portfolio.py +79 -18
  65. wbportfolio/models/portfolio_relationship.py +6 -0
  66. wbportfolio/models/products.py +3 -0
  67. wbportfolio/models/rebalancing.py +4 -1
  68. wbportfolio/models/roles.py +4 -10
  69. wbportfolio/models/transactions/claim.py +6 -5
  70. wbportfolio/models/transactions/dividends.py +1 -0
  71. wbportfolio/models/transactions/trades.py +4 -0
  72. wbportfolio/models/transactions/transactions.py +16 -4
  73. wbportfolio/models/utils.py +100 -1
  74. wbportfolio/order_routing/__init__.py +16 -0
  75. wbportfolio/order_routing/adapters/__init__.py +14 -6
  76. wbportfolio/order_routing/adapters/ubs.py +104 -70
  77. wbportfolio/order_routing/router.py +33 -0
  78. wbportfolio/order_routing/tests/test_router.py +110 -0
  79. wbportfolio/permissions.py +7 -0
  80. wbportfolio/pms/trading/__init__.py +0 -1
  81. wbportfolio/pms/trading/optimizer.py +61 -0
  82. wbportfolio/pms/typing.py +115 -103
  83. wbportfolio/rebalancing/models/composite.py +1 -1
  84. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  85. wbportfolio/risk_management/backends/__init__.py +1 -0
  86. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  87. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  88. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  89. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  90. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  91. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  92. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  93. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  94. wbportfolio/serializers/orders/order_proposals.py +6 -2
  95. wbportfolio/serializers/orders/orders.py +119 -26
  96. wbportfolio/serializers/transactions/claim.py +2 -2
  97. wbportfolio/tasks.py +42 -4
  98. wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
  99. wbportfolio/tests/models/test_portfolios.py +9 -9
  100. wbportfolio/tests/models/test_splits.py +1 -6
  101. wbportfolio/tests/models/test_utils.py +140 -0
  102. wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
  103. wbportfolio/tests/rebalancing/test_models.py +2 -2
  104. wbportfolio/tests/viewsets/test_products.py +1 -0
  105. wbportfolio/urls.py +1 -1
  106. wbportfolio/viewsets/charts/assets.py +8 -4
  107. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  108. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  109. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  110. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  111. wbportfolio/viewsets/esg.py +3 -5
  112. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
  113. wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
  114. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
  115. wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
  116. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
  117. wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
  118. wbportfolio/viewsets/orders/order_proposals.py +92 -21
  119. wbportfolio/viewsets/orders/orders.py +79 -26
  120. wbportfolio/viewsets/portfolios.py +24 -0
  121. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
  122. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
  123. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  124. wbportfolio/fdm/tasks.py +0 -42
  125. wbportfolio/models/orders/routing.py +0 -54
  126. wbportfolio/pms/trading/handler.py +0 -211
  127. /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
  128. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,4 @@
1
+ from contextlib import suppress
1
2
  from datetime import date
2
3
  from decimal import Decimal
3
4
 
@@ -6,12 +7,18 @@ from django.db import models
6
7
  from django.db.models import (
7
8
  Sum,
8
9
  )
10
+ from django.dispatch import receiver
9
11
  from ordered_model.models import OrderedModel
12
+ from pandas import Timestamp
13
+ from wbcore.contrib.currency.models import CurrencyFXRates
10
14
  from wbcore.contrib.io.mixins import ImportMixin
15
+ from wbfdm.models import Instrument
11
16
 
12
17
  from wbportfolio.import_export.handlers.orders import OrderImportHandler
13
18
  from wbportfolio.models.asset import AssetPosition
14
19
  from wbportfolio.models.transactions.transactions import TransactionMixin
20
+ from wbportfolio.order_routing import ExecutionInstruction
21
+ from wbportfolio.pms.typing import Position as PositionDTO
15
22
 
16
23
 
17
24
  class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
@@ -30,6 +37,13 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
30
37
  SELL = "SELL", "Sell"
31
38
  NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
32
39
 
40
+ class ExecutionStatus(models.TextChoices):
41
+ PENDING = "PENDING", "Pending"
42
+ CONFIRMED = "CONFIRMED", "Confirmed"
43
+ EXECUTED = "EXECUTED", "Executed"
44
+ FAILED = "FAILED", "Failed"
45
+ IGNORED = "IGNORED", "Ignored"
46
+
33
47
  order_type = models.CharField(max_length=32, default=Type.BUY, choices=Type.choices, verbose_name="Trade Type")
34
48
  shares = models.DecimalField(
35
49
  max_digits=15,
@@ -67,42 +81,56 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
67
81
  verbose_name="Daily Return",
68
82
  help_text="The Ex-Post daily return",
69
83
  )
70
-
71
- execution_confirmed = models.BooleanField(default=False, verbose_name="Execution Confirmed")
84
+ quantization_error = models.DecimalField(
85
+ max_digits=9,
86
+ decimal_places=ORDER_WEIGHTING_PRECISION,
87
+ default=Decimal(0),
88
+ verbose_name="Quantization Error",
89
+ )
90
+ execution_status = models.CharField(
91
+ max_length=12,
92
+ default=ExecutionStatus.PENDING.value,
93
+ choices=ExecutionStatus.choices,
94
+ verbose_name="Execution Status",
95
+ )
96
+ execution_instruction = models.CharField(
97
+ max_length=26,
98
+ choices=ExecutionInstruction.choices,
99
+ default=ExecutionInstruction.MARKET_ON_CLOSE.value,
100
+ verbose_name="Execution Instruction",
101
+ )
102
+ execution_instruction_parameters = models.JSONField(
103
+ default=dict, blank=True, verbose_name="Execution Instruction Parameters"
104
+ )
72
105
  execution_comment = models.TextField(default="", blank=True, verbose_name="Execution Comment")
73
106
 
74
- def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
75
- warnings = []
107
+ execution_trade = models.OneToOneField(
108
+ to="wbportfolio.Trade",
109
+ related_name="order",
110
+ on_delete=models.SET_NULL,
111
+ blank=True,
112
+ null=True,
113
+ help_text="The executed Trade",
114
+ )
115
+ order_with_respect_to = "order_proposal"
76
116
 
77
- # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
78
- if self.order_proposal and not self.portfolio.only_weighting:
79
- shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
80
- if shares != self.shares:
81
- warnings.append(
82
- f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
83
- )
84
- shares = round(shares) # ensure fractional shares are converted into integer
85
- # we need to recompute the delta weight has we changed the number of shares
86
- if shares != self.shares:
87
- self.shares = shares
88
- if portfolio_total_asset_value:
89
- self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
90
- if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
91
- warnings.append(
92
- f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
93
- )
94
- self.shares = Decimal("0")
95
- self.weighting = Decimal("0")
96
- if not self.price:
97
- warnings.append(f"No price for {self.underlying_instrument.computed_str}")
98
- if (
99
- not self.underlying_instrument.is_cash
100
- and not self.underlying_instrument.is_cash_equivalent
101
- and self._target_weight < -1e-8
102
- ): # any value below -1e8 will be considered zero
103
- warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
104
- self.desired_target_weight = self._target_weight
105
- return warnings
117
+ class Meta(OrderedModel.Meta):
118
+ verbose_name = "Order"
119
+ verbose_name_plural = "Orders"
120
+ indexes = [
121
+ models.Index(fields=["order_proposal"]),
122
+ models.Index(fields=["underlying_instrument", "value_date"]),
123
+ models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
124
+ models.Index(fields=["order_proposal", "underlying_instrument"]),
125
+ # models.Index(fields=["date", "underlying_instrument"]),
126
+ ]
127
+ constraints = [
128
+ models.UniqueConstraint(
129
+ fields=["order_proposal", "underlying_instrument"],
130
+ name="unique_order",
131
+ ),
132
+ ]
133
+ # notification_email_template = "portfolio/email/trade_notification.html"
106
134
 
107
135
  @property
108
136
  def product(self):
@@ -150,12 +178,7 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
150
178
  return getattr(
151
179
  self,
152
180
  "effective_shares",
153
- AssetPosition.objects.filter(
154
- underlying_quote=self.underlying_instrument,
155
- date=self.value_date,
156
- portfolio=self.portfolio,
157
- ).aggregate(s=Sum("shares"))["s"]
158
- or Decimal(0),
181
+ self.get_effective_shares(),
159
182
  )
160
183
 
161
184
  @property
@@ -170,52 +193,38 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
170
193
  def _target_shares(self) -> Decimal:
171
194
  return getattr(self, "target_shares", self._effective_shares + self.shares)
172
195
 
173
- order_with_respect_to = "order_proposal"
174
-
175
- class Meta(OrderedModel.Meta):
176
- verbose_name = "Order"
177
- verbose_name_plural = "Orders"
178
- indexes = [
179
- models.Index(fields=["order_proposal"]),
180
- models.Index(fields=["underlying_instrument", "value_date"]),
181
- models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
182
- models.Index(fields=["order_proposal", "underlying_instrument"]),
183
- # models.Index(fields=["date", "underlying_instrument"]),
184
- ]
185
- constraints = [
186
- models.UniqueConstraint(
187
- fields=["order_proposal", "underlying_instrument"],
188
- name="unique_order",
189
- ),
190
- ]
191
- # notification_email_template = "portfolio/email/trade_notification.html"
196
+ def __str__(self):
197
+ ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
198
+ return f"{ticker}{self.weighting}"
192
199
 
193
200
  def pre_save(self):
194
201
  self.portfolio = self.order_proposal.portfolio
195
202
  self.value_date = self.order_proposal.trade_date
203
+ self.set_currency_fx_rate()
196
204
 
197
205
  if not self.price:
198
- # we try to get the price if not provided directly from the underlying instrument
199
- if self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent:
200
- self.price = Decimal("1")
201
- else:
202
- self.price = self.get_price()
203
- if not self.portfolio.only_weighting:
206
+ self.set_price()
207
+ if not self.portfolio.only_weighting and not self.shares:
204
208
  estimated_shares = self.order_proposal.get_estimated_shares(
205
209
  self.weighting, self.underlying_instrument, self.price
206
210
  )
207
211
  if estimated_shares:
208
212
  self.shares = estimated_shares
213
+ if effective_shares := self.get_effective_shares():
214
+ if self.order_type == self.Type.SELL:
215
+ self.shares = -effective_shares
216
+ else:
217
+ self.shares = max(self.shares, -effective_shares)
209
218
  super().pre_save()
210
219
 
211
220
  def save(self, *args, **kwargs):
221
+ if self.id:
222
+ self.set_type()
212
223
  self.pre_save()
213
224
  if not self.underlying_instrument.is_investable_universe:
214
225
  self.underlying_instrument.is_investable_universe = True
215
226
  self.underlying_instrument.save()
216
227
 
217
- if self.id:
218
- self.set_type()
219
228
  super().save(*args, **kwargs)
220
229
 
221
230
  @classmethod
@@ -234,15 +243,168 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
234
243
  else:
235
244
  return Order.Type.SELL
236
245
 
246
+ def get_effective_shares(self) -> Decimal:
247
+ return AssetPosition.objects.filter(
248
+ underlying_quote=self.underlying_instrument,
249
+ date=self.order_proposal.last_effective_date,
250
+ portfolio=self.portfolio,
251
+ ).aggregate(s=Sum("shares"))["s"] or Decimal("0")
252
+
237
253
  def set_type(self):
238
- self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
254
+ effective_weight = self._effective_weight
255
+ self.order_type = self.get_type(self.weighting, effective_weight, effective_weight + self.weighting)
239
256
 
240
- def get_price(self) -> Decimal:
241
- try:
242
- return self.underlying_instrument.get_price(self.value_date)
243
- except ValueError:
244
- return Decimal("0")
257
+ def _get_price(self) -> tuple[Decimal, Decimal]:
258
+ daily_return = last_price = Decimal("0")
245
259
 
246
- def __str__(self):
247
- ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
248
- return f"{ticker}{self.weighting}"
260
+ effective_date = self.order_proposal.last_effective_date
261
+ if self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent:
262
+ last_price = Decimal("1")
263
+ else:
264
+ try:
265
+ last_price = Decimal(self.portfolio.builder.prices[self.value_date][self.underlying_instrument.id])
266
+ daily_return = self.portfolio.builder.returns.loc[
267
+ Timestamp(self.value_date), self.underlying_instrument.id
268
+ ]
269
+ except KeyError:
270
+ prices, returns = Instrument.objects.filter(id=self.underlying_instrument.id).get_returns_df(
271
+ from_date=effective_date,
272
+ to_date=self.value_date,
273
+ to_currency=self.order_proposal.portfolio.currency,
274
+ use_dl=True,
275
+ )
276
+ with suppress(IndexError):
277
+ daily_return = Decimal(returns.iloc[-1, 0])
278
+ with suppress(KeyError, TypeError):
279
+ last_price = Decimal(
280
+ prices.get(self.value_date, prices[effective_date])[self.underlying_instrument.id]
281
+ )
282
+ return last_price, daily_return
283
+
284
+ def set_price(self):
285
+ last_price, daily_return = self._get_price()
286
+ self.daily_return = daily_return
287
+ self.price = last_price
288
+
289
+ def set_currency_fx_rate(self):
290
+ self.currency_fx_rate = Decimal("1")
291
+ if self.order_proposal.portfolio.currency != self.underlying_instrument.currency:
292
+ with suppress(CurrencyFXRates.DoesNotExist):
293
+ self.currency_fx_rate = self.underlying_instrument.currency.convert(
294
+ self.value_date, self.portfolio.currency, exact_lookup=True
295
+ )
296
+
297
+ def set_weighting(self, weighting: Decimal, portfolio_value: Decimal):
298
+ self.weighting = weighting
299
+ price_fx_portfolio = self.price * self.currency_fx_rate
300
+ if price_fx_portfolio and portfolio_value:
301
+ total_value = self.weighting * portfolio_value
302
+ self.shares = total_value / price_fx_portfolio
303
+ else:
304
+ self.shares = Decimal("0")
305
+
306
+ def set_shares(self, shares: Decimal, portfolio_value: Decimal):
307
+ if portfolio_value:
308
+ price_fx_portfolio = self.price * self.currency_fx_rate
309
+ self.shares = shares
310
+ total_value = shares * price_fx_portfolio
311
+ self.weighting = total_value / portfolio_value
312
+ else:
313
+ self.weighting = self.shares = Decimal("0")
314
+
315
+ def set_total_value_fx_portfolio(self, total_value_fx_portfolio: Decimal, portfolio_value: Decimal):
316
+ price_fx_portfolio = self.price * self.currency_fx_rate
317
+ if price_fx_portfolio and portfolio_value:
318
+ self.shares = total_value_fx_portfolio / price_fx_portfolio
319
+ self.weighting = total_value_fx_portfolio / portfolio_value
320
+ else:
321
+ self.weighting = self.shares = Decimal("0")
322
+
323
+ def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
324
+ warnings = []
325
+ # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
326
+ if self._target_weight:
327
+ if self.order_proposal and not self.portfolio.only_weighting:
328
+ shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
329
+ if shares != self.shares:
330
+ warnings.append(
331
+ f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
332
+ )
333
+ shares = round(shares) # ensure fractional shares are converted into integer
334
+ # we need to recompute the delta weight has we changed the number of shares
335
+ if shares != self.shares:
336
+ self.set_shares(shares, portfolio_total_asset_value)
337
+ if abs(self.weighting) < self.order_proposal.min_weighting:
338
+ warnings.append(
339
+ f"Weighting for order {self.underlying_instrument.computed_str} ({self.weighting}) is bellow the allowed Minimum Weighting ({self.order_proposal.min_weighting})"
340
+ )
341
+ self.set_weighting(Decimal("0"), portfolio_total_asset_value)
342
+ if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
343
+ warnings.append(
344
+ f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
345
+ )
346
+ self.set_weighting(Decimal("0"), portfolio_total_asset_value)
347
+ if not self.price:
348
+ warnings.append(f"No price for {self.underlying_instrument.computed_str}")
349
+ if (
350
+ not self.underlying_instrument.is_cash
351
+ and not self.underlying_instrument.is_cash_equivalent
352
+ and self._target_weight < -1e-8
353
+ ): # any value below -1e8 will be considered zero
354
+ warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
355
+ self.desired_target_weight = self._target_weight
356
+ return warnings
357
+
358
+ def to_dto(self) -> PositionDTO:
359
+ return self.create_dto(
360
+ self.underlying_instrument,
361
+ self._target_weight,
362
+ self.price,
363
+ self.value_date,
364
+ shares=self._target_shares,
365
+ currency_fx_rate=self.currency_fx_rate,
366
+ daily_return=self.daily_return,
367
+ )
368
+
369
+ @classmethod
370
+ def create_dto(
371
+ cls,
372
+ instrument: Instrument,
373
+ weighting: Decimal,
374
+ price: Decimal,
375
+ trade_date: date,
376
+ shares: Decimal | None = None,
377
+ **extra_param,
378
+ ) -> PositionDTO:
379
+ price_data = {}
380
+
381
+ return PositionDTO(
382
+ underlying_instrument=instrument.id,
383
+ instrument_type=instrument.security_instrument_type.id,
384
+ weighting=weighting,
385
+ shares=shares,
386
+ currency=instrument.currency.id,
387
+ date=trade_date,
388
+ asset_valuation_date=trade_date,
389
+ is_cash=instrument.is_cash or instrument.is_cash_equivalent,
390
+ price=price,
391
+ exchange=instrument.exchange.id if instrument.exchange else None,
392
+ country=instrument.country.id if instrument.country else None,
393
+ **price_data,
394
+ **extra_param,
395
+ )
396
+
397
+
398
+ @receiver(models.signals.post_save, sender="wbportfolio.Trade")
399
+ def link_trade_to_order(sender, instance, created, raw, **kwargs):
400
+ """Gets or create the fees for a given price and updates them if necessary"""
401
+ if not raw and created and not instance.underlying_instrument.is_cash and not instance.is_customer_trade:
402
+ with suppress(Order.DoesNotExist):
403
+ order = Order.objects.get(
404
+ portfolio=instance.portfolio,
405
+ underlying_instrument=instance.underlying_instrument,
406
+ value_date=instance.book_date,
407
+ )
408
+ order.execution_trade = instance
409
+ order.execution_status = Order.ExecutionStatus.EXECUTED
410
+ order.save()
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from collections import defaultdict
2
3
  from contextlib import suppress
3
4
  from datetime import date, timedelta
4
5
  from decimal import Decimal
@@ -9,6 +10,7 @@ import pandas as pd
9
10
  from celery import shared_task
10
11
  from django.contrib.postgres.fields import DateRangeField
11
12
  from django.core.exceptions import ObjectDoesNotExist
13
+ from django.core.validators import MaxValueValidator, MinValueValidator
12
14
  from django.db import models
13
15
  from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
14
16
  from django.db.models.signals import post_save
@@ -16,7 +18,9 @@ from django.dispatch import receiver
16
18
  from django.utils import timezone
17
19
  from django.utils.functional import cached_property
18
20
  from pandas._libs.tslibs.offsets import BDay
21
+ from wbcore.contrib.authentication.models import User
19
22
  from wbcore.contrib.currency.models import Currency
23
+ from wbcore.contrib.notifications.dispatch import send_notification
20
24
  from wbcore.contrib.notifications.utils import create_notification_type
21
25
  from wbcore.models import WBModel
22
26
  from wbcore.utils.importlib import import_from_dotted_path
@@ -39,7 +43,7 @@ from wbportfolio.pms.typing import Position as PositionDTO
39
43
 
40
44
  from ..constants import EQUITY_TYPE_KEYS
41
45
  from ..order_routing.adapters import BaseCustodianAdapter
42
- from . import ProductGroup
46
+ from . import PortfolioRole, ProductGroup
43
47
 
44
48
  logger = logging.getLogger("pms")
45
49
  if TYPE_CHECKING:
@@ -71,7 +75,7 @@ class DefaultPortfolioQueryset(QuerySet):
71
75
  """
72
76
  A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
73
77
  """
74
- MAX_ITERATIONS: int = (
78
+ max_iterations: int = (
75
79
  5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
76
80
  )
77
81
  remaining_portfolios = set(self)
@@ -84,7 +88,7 @@ class DefaultPortfolioQueryset(QuerySet):
84
88
  dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
85
89
  portfolio=p, dependency_portfolio__in=remaining_portfolios
86
90
  ) # get dependency portfolios
87
- if iterator_counter >= MAX_ITERATIONS or (
91
+ if iterator_counter >= max_iterations or (
88
92
  not dependency_relationships.exists() and not bool(parent_portfolios)
89
93
  ): # if not dependency portfolio or parent portfolio that remained, then we yield
90
94
  remaining_portfolios.remove(p)
@@ -125,22 +129,25 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
125
129
 
126
130
  class PortfolioPortfolioThroughModel(models.Model):
127
131
  class Type(models.TextChoices):
128
- PRIMARY = "PRIMARY", "Primary"
132
+ LOOK_THROUGH = "LOOK_THROUGH", "Look-through"
129
133
  MODEL = "MODEL", "Model"
130
134
  CUSTODIAN = "CUSTODIAN", "Custodian"
135
+ HIERARCHICAL = "HIERARCHICAL", "Hierarchical"
131
136
 
132
137
  portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
133
138
  dependency_portfolio = models.ForeignKey(
134
139
  "wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependent_through"
135
140
  )
136
- type = models.CharField(choices=Type.choices, default=Type.PRIMARY, verbose_name="Type")
141
+ type = models.CharField(choices=Type.choices, default=Type.LOOK_THROUGH, verbose_name="Type")
137
142
 
138
143
  def __str__(self):
139
144
  return f"{self.portfolio} dependant on {self.dependency_portfolio} ({self.Type[self.type].label})"
140
145
 
141
146
  class Meta:
142
147
  constraints = [
143
- models.UniqueConstraint(fields=["portfolio", "type"], name="unique_primary", condition=Q(type="PRIMARY")),
148
+ models.UniqueConstraint(
149
+ fields=["portfolio", "type"], name="unique_lookthrough", condition=Q(type="LOOK_THROUGH")
150
+ ),
144
151
  models.UniqueConstraint(fields=["portfolio", "type"], name="unique_model", condition=Q(type="MODEL")),
145
152
  ]
146
153
 
@@ -233,6 +240,25 @@ class Portfolio(DeleteToDisableMixin, WBModel):
233
240
  blank=True,
234
241
  )
235
242
 
243
+ # OMS default parameters. Used to seed order proposal default value upon creation
244
+ default_order_proposal_min_order_value = models.IntegerField(
245
+ default=0, verbose_name="Default Order Proposal Minimum Order Value"
246
+ )
247
+ default_order_proposal_min_weighting = models.DecimalField(
248
+ max_digits=9,
249
+ decimal_places=8,
250
+ default=Decimal(0),
251
+ verbose_name="Default Order Proposal Minimum Weight",
252
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
253
+ )
254
+ default_order_proposal_total_cash_weight = models.DecimalField(
255
+ default=Decimal("0"),
256
+ decimal_places=4,
257
+ max_digits=5,
258
+ verbose_name="Default Order Proposal Total Cash Weight",
259
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
260
+ )
261
+
236
262
  objects = DefaultPortfolioManager()
237
263
  tracked_objects = ActiveTrackedPortfolioManager()
238
264
 
@@ -244,7 +270,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
244
270
  def primary_portfolio(self):
245
271
  with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
246
272
  return PortfolioPortfolioThroughModel.objects.get(
247
- portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY
273
+ portfolio=self, type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
248
274
  ).dependency_portfolio
249
275
 
250
276
  @property
@@ -402,12 +428,22 @@ class Portfolio(DeleteToDisableMixin, WBModel):
402
428
  True,
403
429
  ),
404
430
  create_notification_type(
405
- "wbportfolio.portfolio.replay_done",
406
- "Portfolio Replay finished",
407
- "Sends a notification when a the requested order proposal replay is done",
431
+ "wbportfolio.portfolio.action_done",
432
+ "Portfolio Action finished",
433
+ "Sends a notification when a the requested portfolio action is done (e.g. replay, quote adjustment...)",
434
+ True,
435
+ True,
436
+ True,
437
+ is_lock=True,
438
+ ),
439
+ create_notification_type(
440
+ "wbportfolio.portfolio.warning",
441
+ "PMS Warning",
442
+ "Sends a notification to warn portfolio manager or administrator regarding issue that needs action.",
408
443
  True,
409
444
  True,
410
445
  True,
446
+ is_lock=True,
411
447
  ),
412
448
  ]
413
449
 
@@ -694,6 +730,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
694
730
  broadcast_changes_at_date: bool = True,
695
731
  **kwargs,
696
732
  ):
733
+ if not self.is_tracked:
734
+ return
697
735
  logger.info(f"change at date for {self} at {val_date}")
698
736
 
699
737
  if fix_quantization:
@@ -726,7 +764,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
726
764
  if self.is_tracked:
727
765
  for rel in PortfolioPortfolioThroughModel.objects.filter(
728
766
  dependency_portfolio=self,
729
- type=PortfolioPortfolioThroughModel.Type.PRIMARY,
767
+ type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH,
730
768
  portfolio__is_lookthrough=True,
731
769
  ):
732
770
  rel.portfolio.compute_lookthrough(val_date)
@@ -818,7 +856,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
818
856
  break
819
857
  try:
820
858
  order_proposal = self.order_proposals.get(
821
- trade_date=to_date, rebalancing_model__isnull=True, status="APPLIED"
859
+ trade_date=to_date, rebalancing_model__isnull=True, status="CONFIRMED"
822
860
  )
823
861
  except ObjectDoesNotExist:
824
862
  if rebalancer and rebalancer.is_valid(to_date):
@@ -929,7 +967,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
929
967
  except IndexError:
930
968
  position.portfolio_created = None
931
969
 
932
- setattr(position, "path", path)
970
+ position.path = path
933
971
  position.initial_shares = None
934
972
  if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
935
973
  position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
@@ -962,13 +1000,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
962
1000
  )
963
1001
  if not to_date:
964
1002
  to_date = from_date
965
- for from_date in pd.date_range(from_date, to_date, freq="B").date:
966
- logger.info(f"Compute Look-Through for {self} at {from_date}")
1003
+ for val_date in pd.date_range(from_date, to_date, freq="B").date:
1004
+ logger.info(f"Compute Look-Through for {self} at {val_date}")
967
1005
  portfolio_total_asset_value = (
968
- self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
1006
+ self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
969
1007
  )
970
1008
  self.builder.add(
971
- list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
1009
+ list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
972
1010
  infer_underlying_quote_price=True,
973
1011
  )
974
1012
  self.builder.bulk_create_positions(delete_leftovers=True)
@@ -1194,7 +1232,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
1194
1232
  not raw
1195
1233
  and created
1196
1234
  and instance.portfolio.is_lookthrough
1197
- and instance.type == PortfolioPortfolioThroughModel.Type.PRIMARY
1235
+ and instance.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
1198
1236
  ):
1199
1237
  with suppress(AssetPosition.DoesNotExist):
1200
1238
  earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
@@ -1219,10 +1257,33 @@ def update_portfolio_after_investable_universe(*args, end_date: date | None = No
1219
1257
  end_date = date.today()
1220
1258
  end_date = (end_date + timedelta(days=1) - BDay(1)).date() # shift in case of business day
1221
1259
  from_date = (end_date - BDay(1)).date()
1260
+ excluded_positions = defaultdict(list)
1222
1261
  for portfolio in Portfolio.tracked_objects.all().to_dependency_iterator(from_date):
1223
1262
  if not portfolio.is_lookthrough:
1224
1263
  try:
1225
1264
  portfolio.propagate_or_update_assets(from_date, end_date)
1265
+ for positions in portfolio.builder.excluded_positions.values():
1266
+ for pos in positions:
1267
+ excluded_positions[pos.underlying_quote].append(portfolio)
1268
+ portfolio.builder.clear()
1226
1269
  except Exception as e:
1227
1270
  logger.error(f"Exception while propagating portfolio assets {portfolio}: {e}")
1228
1271
  portfolio.estimate_net_asset_values(end_date)
1272
+ # if there were excluded positions, we compiled a itemized list of quote per portfolio that got excluded and warn the current portfolio manager
1273
+ if excluded_positions:
1274
+ body = (
1275
+ "<p>While drifting the portfolios, the following quotes got excluded because of missing prices: </p><ul>"
1276
+ )
1277
+ for quote, portfolios in excluded_positions.items():
1278
+ body += f"<li>{quote}</li><p>Impacted portfolios: </p><ul>"
1279
+ for portfolio in portfolios:
1280
+ body += f"<li>{portfolio}</li>"
1281
+ body += "</ul>"
1282
+ body += "</ul> <p>Note: If the quote has simply changed its primary exchange, please use the adjustment tool provided. Otherwise, please contact a system administrator.</p>"
1283
+ for user in User.objects.filter(profile__in=PortfolioRole.portfolio_managers(), is_active=True):
1284
+ send_notification(
1285
+ code="wbportfolio.portfolio.warning",
1286
+ title="Positions were automatically excluded",
1287
+ body=body,
1288
+ user=user,
1289
+ )
@@ -61,6 +61,9 @@ class InstrumentPortfolioThroughModel(models.Model):
61
61
  models.UniqueConstraint(fields=["instrument", "portfolio"], name="unique_portfolio_relationship"),
62
62
  ]
63
63
 
64
+ def __str__(self) -> str:
65
+ return f"{self.instrument} - {self.portfolio}"
66
+
64
67
  @classmethod
65
68
  def get_portfolio(cls, instrument):
66
69
  with suppress(InstrumentPortfolioThroughModel.DoesNotExist):
@@ -99,6 +102,9 @@ class PortfolioInstrumentPreferredClassificationThroughModel(models.Model):
99
102
  related_name="preferred_classification_group_throughs",
100
103
  )
101
104
 
105
+ def __str__(self) -> str:
106
+ return f"{self.portfolio} - {self.instrument}: ({self.classification})"
107
+
102
108
  def save(self, *args, **kwargs) -> None:
103
109
  if not self.classification_group and self.classification:
104
110
  self.classification_group = self.classification.group
@@ -190,6 +190,9 @@ class FeeProductPercentage(models.Model):
190
190
  ),
191
191
  ]
192
192
 
193
+ def __str__(self) -> str:
194
+ return f"{self.product.name} ({self.type})"
195
+
193
196
  @property
194
197
  def net_percent(self) -> Decimal:
195
198
  return self.percent
@@ -89,6 +89,9 @@ class Rebalancer(ComplexToStringMixin, models.Model):
89
89
  help_text=_("The Evaluation Frequency in RRULE format"),
90
90
  )
91
91
 
92
+ def __str__(self) -> str:
93
+ return f"{self.portfolio.name} ({self.rebalancing_model})"
94
+
92
95
  def save(self, *args, **kwargs):
93
96
  if not self.activation_date:
94
97
  try:
@@ -108,7 +111,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
108
111
  def is_valid(self, trade_date: date) -> bool:
109
112
  if OrderProposal.objects.filter(
110
113
  portfolio=self.portfolio,
111
- status=OrderProposal.Status.APPLIED,
114
+ status=OrderProposal.Status.CONFIRMED,
112
115
  trade_date=trade_date,
113
116
  rebalancing_model__isnull=True,
114
117
  ).exists(): # if a already applied order proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")