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.
- wbportfolio/admin/orders/order_proposals.py +2 -0
- wbportfolio/admin/orders/orders.py +2 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/api_clients/ubs.py +23 -11
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +8 -3
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/orders/order_proposals.py +3 -6
- wbportfolio/filters/portfolios.py +18 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/handlers/asset_position.py +9 -5
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +2 -2
- wbportfolio/import_export/handlers/trade.py +4 -4
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/resources/trades.py +1 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +7 -3
- wbportfolio/models/builder.py +25 -5
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
- wbportfolio/models/orders/order_proposals.py +620 -490
- wbportfolio/models/orders/orders.py +237 -75
- wbportfolio/models/portfolio.py +79 -18
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +4 -1
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/claim.py +6 -5
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/trades.py +4 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +16 -0
- wbportfolio/order_routing/adapters/__init__.py +14 -6
- wbportfolio/order_routing/adapters/ubs.py +104 -70
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +115 -103
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/orders/order_proposals.py +6 -2
- wbportfolio/serializers/orders/orders.py +119 -26
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
- wbportfolio/tests/models/test_portfolios.py +9 -9
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
- wbportfolio/tests/rebalancing/test_models.py +2 -2
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +1 -1
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
- wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
- wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
- wbportfolio/viewsets/orders/order_proposals.py +92 -21
- wbportfolio/viewsets/orders/orders.py +79 -26
- wbportfolio/viewsets/portfolios.py +24 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/fdm/tasks.py +0 -42
- wbportfolio/models/orders/routing.py +0 -54
- wbportfolio/pms/trading/handler.py +0 -211
- /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
- {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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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()
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
406
|
-
"Portfolio
|
|
407
|
-
"Sends a notification when a the requested
|
|
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.
|
|
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="
|
|
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
|
-
|
|
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
|
|
966
|
-
logger.info(f"Compute Look-Through for {self} at {
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
wbportfolio/models/products.py
CHANGED
|
@@ -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.
|
|
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")
|