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.
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/builder.py +70 -25
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +510 -161
- wbportfolio/models/orders/orders.py +20 -10
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +76 -41
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -0
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +23 -3
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/viewsets/charts/assets.py +4 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +45 -6
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/builder.py
CHANGED
|
@@ -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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
80
|
-
fx_rate = CurrencyFXRates.objects.
|
|
81
|
-
currency=currency, date=val_date
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
|
124
|
-
self.returns
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
from_date
|
|
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,
|