wbportfolio 1.54.21__py2.py3-none-any.whl → 1.54.23__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/constants.py +1 -0
- wbportfolio/factories/orders/orders.py +10 -2
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +11 -11
- wbportfolio/models/orders/order_proposals.py +53 -20
- wbportfolio/models/orders/orders.py +4 -1
- wbportfolio/models/portfolio.py +16 -8
- wbportfolio/models/products.py +9 -0
- wbportfolio/pms/trading/handler.py +0 -1
- wbportfolio/serializers/orders/order_proposals.py +2 -18
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +61 -15
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +337 -154
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +12 -2
- wbportfolio/viewsets/orders/order_proposals.py +2 -1
- wbportfolio/viewsets/positions.py +3 -2
- {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/RECORD +33 -32
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/licenses/LICENSE +0 -0
wbportfolio/constants.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
EQUITY_TYPE_KEYS = ["american_depository_receipt", "equity"] # TODO might want to move this into a dynamic preference
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import random
|
|
2
1
|
from decimal import Decimal
|
|
3
2
|
|
|
4
3
|
import factory
|
|
5
4
|
from faker import Faker
|
|
5
|
+
from wbfdm.factories import InstrumentPriceFactory
|
|
6
6
|
|
|
7
7
|
from wbportfolio.models import Order
|
|
8
8
|
|
|
@@ -18,4 +18,12 @@ class OrderFactory(factory.django.DjangoModelFactory):
|
|
|
18
18
|
fees = Decimal(0.0)
|
|
19
19
|
underlying_instrument = factory.SubFactory("wbfdm.factories.InstrumentFactory")
|
|
20
20
|
shares = factory.Faker("pydecimal", min_value=10, max_value=1000, right_digits=4)
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
@factory.post_generation
|
|
23
|
+
def create_price(self, create, extracted, **kwargs):
|
|
24
|
+
if create:
|
|
25
|
+
p = InstrumentPriceFactory.create(
|
|
26
|
+
instrument=self.underlying_instrument, date=self.value_date, calculated=False
|
|
27
|
+
)
|
|
28
|
+
self.price = p.net_value
|
|
29
|
+
self.save()
|
wbportfolio/filters/assets.py
CHANGED
|
@@ -400,8 +400,8 @@ class DistributionFilter(wb_filters.FilterSet):
|
|
|
400
400
|
|
|
401
401
|
group_by = wb_filters.ChoiceFilter(
|
|
402
402
|
label="Group By",
|
|
403
|
-
choices=AssetPositionGroupBy.choices
|
|
404
|
-
initial=AssetPositionGroupBy.INDUSTRY.
|
|
403
|
+
choices=AssetPositionGroupBy.choices,
|
|
404
|
+
initial=AssetPositionGroupBy.INDUSTRY.value,
|
|
405
405
|
method="fake_filter",
|
|
406
406
|
clearable=False,
|
|
407
407
|
required=True,
|
|
@@ -427,6 +427,14 @@ class DistributionFilter(wb_filters.FilterSet):
|
|
|
427
427
|
endpoint=ClassificationGroup.get_representation_endpoint(),
|
|
428
428
|
value_key=ClassificationGroup.get_representation_value_key(),
|
|
429
429
|
label_key=ClassificationGroup.get_representation_label_key(),
|
|
430
|
+
depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
group_by_classification_height = wb_filters.NumberFilter(
|
|
434
|
+
method="fake_filter",
|
|
435
|
+
label="Classification Height",
|
|
436
|
+
initial=0,
|
|
437
|
+
depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
|
|
430
438
|
)
|
|
431
439
|
|
|
432
440
|
class Meta:
|
|
@@ -32,7 +32,7 @@ class OrderImportHandler(ImportExportHandler):
|
|
|
32
32
|
self.order_proposal = OrderProposal.objects.get(id=data.pop("order_proposal_id"))
|
|
33
33
|
weighting = data.get("target_weight", data.get("weighting"))
|
|
34
34
|
shares = data.get("target_shares", data.get("shares", 0))
|
|
35
|
-
if
|
|
35
|
+
if weighting is None:
|
|
36
36
|
raise DeserializationError("We couldn't figure out the target weight column")
|
|
37
37
|
position_dto = Position(
|
|
38
38
|
underlying_instrument=underlying_instrument.id,
|
|
@@ -10,7 +10,10 @@ def parse(import_source):
|
|
|
10
10
|
with suppress(KeyError):
|
|
11
11
|
series_data = json.loads(import_source.file.read())["payload"]["series"]
|
|
12
12
|
for series in series_data:
|
|
13
|
-
|
|
13
|
+
try:
|
|
14
|
+
isin = series["key"]["priceIdentifier"]
|
|
15
|
+
except KeyError:
|
|
16
|
+
isin = series["priceIdentifier"]
|
|
14
17
|
if Product.objects.filter(isin=isin).exists(): # ensure the timeseries contain data for products we handle
|
|
15
18
|
for point in series["points"]:
|
|
16
19
|
data.append(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-08-04 09:30
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('wbportfolio', '0085_order_desired_target_weight'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='orderproposal',
|
|
16
|
+
name='total_cash_weight',
|
|
17
|
+
field=models.DecimalField(decimal_places=4, default=Decimal('0'), help_text='The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.', max_digits=5, verbose_name='Total Cash Weight'),
|
|
18
|
+
),
|
|
19
|
+
]
|
wbportfolio/models/asset.py
CHANGED
|
@@ -30,7 +30,6 @@ from pandas._libs.tslibs.offsets import BDay
|
|
|
30
30
|
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
31
31
|
from wbcore.contrib.io.mixins import ImportMixin
|
|
32
32
|
from wbcore.signals import pre_merge
|
|
33
|
-
from wbcore.utils.enum import ChoiceEnum
|
|
34
33
|
from wbfdm.models import Classification, ClassificationGroup, Instrument
|
|
35
34
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
36
35
|
from wbfdm.signals import add_instrument_to_investable_universe
|
|
@@ -223,25 +222,12 @@ class AnalyticalAssetPositionManager(DefaultAssetPositionManager):
|
|
|
223
222
|
)
|
|
224
223
|
|
|
225
224
|
|
|
226
|
-
class AssetPositionGroupBy(
|
|
227
|
-
INDUSTRY = "Industry"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
LIQUIDITY = "Liquidity"
|
|
233
|
-
|
|
234
|
-
@classmethod
|
|
235
|
-
def get_class_method_group_by(cls, name: str):
|
|
236
|
-
_map = {
|
|
237
|
-
"INDUSTRY": "industry",
|
|
238
|
-
"COUNTRY": AssetPosition.country_group_by,
|
|
239
|
-
"CURRENCY": AssetPosition.currency_group_by,
|
|
240
|
-
"CASH": AssetPosition.cash_group_by,
|
|
241
|
-
"MARKET_CAPITALIZATION": AssetPosition.marketcap_group_by,
|
|
242
|
-
"LIQUIDITY": AssetPosition.liquidity_group_by,
|
|
243
|
-
}
|
|
244
|
-
return _map[name]
|
|
225
|
+
class AssetPositionGroupBy(models.TextChoices):
|
|
226
|
+
INDUSTRY = "classification", "Industry"
|
|
227
|
+
INSTRUMENT_TYPE = "instrument_type", "Type"
|
|
228
|
+
COUNTRY = "country", "Country"
|
|
229
|
+
CURRENCY = "currency", "Currency"
|
|
230
|
+
CASH = "is_cash", "Cash"
|
|
245
231
|
|
|
246
232
|
|
|
247
233
|
class AssetPosition(ImportMixin, models.Model):
|
wbportfolio/models/builder.py
CHANGED
|
@@ -94,7 +94,6 @@ class AssetPositionBuilder:
|
|
|
94
94
|
val_date: date,
|
|
95
95
|
instrument_id: int,
|
|
96
96
|
weighting: float,
|
|
97
|
-
infer_underlying_quote_price: bool = False,
|
|
98
97
|
**kwargs,
|
|
99
98
|
) -> "AssetPosition":
|
|
100
99
|
underlying_quote = self._get_instrument(instrument_id)
|
|
@@ -119,9 +118,6 @@ class AssetPositionBuilder:
|
|
|
119
118
|
)
|
|
120
119
|
parameters.update(kwargs)
|
|
121
120
|
position = AssetPosition(**parameters)
|
|
122
|
-
position.pre_save(
|
|
123
|
-
infer_underlying_quote_price=infer_underlying_quote_price
|
|
124
|
-
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
125
121
|
return position
|
|
126
122
|
|
|
127
123
|
def set_return(self, returns: pd.DataFrame):
|
|
@@ -130,9 +126,9 @@ class AssetPositionBuilder:
|
|
|
130
126
|
def set_prices(self, prices: dict[date, dict[int, float]] | None = None):
|
|
131
127
|
self.prices = prices
|
|
132
128
|
|
|
133
|
-
def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date):
|
|
129
|
+
def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date, use_dl: bool = True):
|
|
134
130
|
self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
|
|
135
|
-
from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=
|
|
131
|
+
from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
|
|
136
132
|
)
|
|
137
133
|
|
|
138
134
|
def add(
|
|
@@ -151,8 +147,10 @@ class AssetPositionBuilder:
|
|
|
151
147
|
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
152
148
|
for position in positions:
|
|
153
149
|
if not isinstance(position, AssetPosition):
|
|
154
|
-
position = self._dict_to_model(*position
|
|
155
|
-
|
|
150
|
+
position = self._dict_to_model(*position)
|
|
151
|
+
position.pre_save(
|
|
152
|
+
infer_underlying_quote_price=infer_underlying_quote_price
|
|
153
|
+
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
156
154
|
# Generate unique composite key
|
|
157
155
|
key = (
|
|
158
156
|
position.underlying_quote.id,
|
|
@@ -221,9 +219,11 @@ class AssetPositionBuilder:
|
|
|
221
219
|
|
|
222
220
|
for val_date in self.get_dates():
|
|
223
221
|
if self.portfolio.is_tracked:
|
|
224
|
-
|
|
222
|
+
try:
|
|
225
223
|
changed_portfolio = self._get_portfolio(val_date)
|
|
226
|
-
|
|
224
|
+
except KeyError:
|
|
225
|
+
changed_portfolio = None
|
|
226
|
+
self._change_at_date_tasks[val_date] = changed_portfolio
|
|
227
227
|
self._compute_metrics_tasks.add(val_date)
|
|
228
228
|
self._positions = defaultdict(dict)
|
|
229
229
|
|
|
@@ -237,7 +237,7 @@ class AssetPositionBuilder:
|
|
|
237
237
|
for d in self._compute_metrics_tasks
|
|
238
238
|
]
|
|
239
239
|
).apply_async()
|
|
240
|
-
self._change_at_date_tasks =
|
|
240
|
+
self._change_at_date_tasks = dict()
|
|
241
241
|
|
|
242
242
|
def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
|
|
243
243
|
from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
|
|
@@ -81,6 +81,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
81
81
|
min_order_value = models.IntegerField(
|
|
82
82
|
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
83
83
|
)
|
|
84
|
+
total_cash_weight = models.DecimalField(
|
|
85
|
+
default=Decimal("0"),
|
|
86
|
+
decimal_places=4,
|
|
87
|
+
max_digits=5,
|
|
88
|
+
verbose_name="Total Cash Weight",
|
|
89
|
+
help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
90
|
+
)
|
|
84
91
|
|
|
85
92
|
class Meta:
|
|
86
93
|
verbose_name = "Order Proposal"
|
|
@@ -162,6 +169,10 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
162
169
|
currency=self.portfolio.currency, defaults={"is_cash": True, "name": self.portfolio.currency.title}
|
|
163
170
|
)[0]
|
|
164
171
|
|
|
172
|
+
@property
|
|
173
|
+
def total_expected_target_weight(self) -> Decimal:
|
|
174
|
+
return Decimal("1") - self.total_cash_weight
|
|
175
|
+
|
|
165
176
|
def get_orders(self):
|
|
166
177
|
base_qs = self.orders.all().annotate(
|
|
167
178
|
last_effective_date=Subquery(
|
|
@@ -236,7 +247,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
236
247
|
def __str__(self) -> str:
|
|
237
248
|
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
238
249
|
|
|
239
|
-
def convert_to_portfolio(
|
|
250
|
+
def convert_to_portfolio(
|
|
251
|
+
self, use_effective: bool = False, with_cash: bool = True, use_desired_target_weight: bool = False
|
|
252
|
+
) -> PortfolioDTO:
|
|
240
253
|
"""
|
|
241
254
|
Data Transfer Object
|
|
242
255
|
Returns:
|
|
@@ -252,9 +265,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
252
265
|
currency_fx_rate=asset._currency_fx_rate,
|
|
253
266
|
)
|
|
254
267
|
for order in self.get_orders():
|
|
268
|
+
previous_weight = order._previous_weight
|
|
269
|
+
if use_desired_target_weight and order.desired_target_weight is not None:
|
|
270
|
+
delta_weight = order.desired_target_weight - previous_weight
|
|
271
|
+
else:
|
|
272
|
+
delta_weight = order.weighting
|
|
255
273
|
portfolio[order.underlying_instrument] = dict(
|
|
256
|
-
weighting=
|
|
257
|
-
delta_weight=
|
|
274
|
+
weighting=previous_weight,
|
|
275
|
+
delta_weight=delta_weight,
|
|
258
276
|
shares=order._target_shares if not use_effective else order._effective_shares,
|
|
259
277
|
price=order.price,
|
|
260
278
|
currency_fx_rate=order.currency_fx_rate,
|
|
@@ -272,6 +290,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
272
290
|
for instrument, row in portfolio.items():
|
|
273
291
|
weighting = row["weighting"]
|
|
274
292
|
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
293
|
+
|
|
275
294
|
if not use_effective:
|
|
276
295
|
drifted_weight = (
|
|
277
296
|
round(
|
|
@@ -297,7 +316,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
297
316
|
)
|
|
298
317
|
)
|
|
299
318
|
total_weighting += weighting
|
|
300
|
-
if portfolio and with_cash and (cash_weight := Decimal("1") - total_weighting):
|
|
319
|
+
if portfolio and with_cash and total_weighting and (cash_weight := Decimal("1") - total_weighting):
|
|
301
320
|
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
302
321
|
positions.append(cash_position._build_dto())
|
|
303
322
|
return PortfolioDTO(positions)
|
|
@@ -332,7 +351,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
332
351
|
|
|
333
352
|
return order_proposal_clone
|
|
334
353
|
|
|
335
|
-
def normalize_orders(self
|
|
354
|
+
def normalize_orders(self):
|
|
336
355
|
"""
|
|
337
356
|
Call the trading service with the existing orders and normalize them in order to obtain a total sum target weight of 100%
|
|
338
357
|
The existing order will be modified directly with the given normalization factor
|
|
@@ -341,7 +360,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
341
360
|
self.trade_date,
|
|
342
361
|
effective_portfolio=self._get_default_effective_portfolio(),
|
|
343
362
|
target_portfolio=self.convert_to_portfolio(use_effective=False, with_cash=False),
|
|
344
|
-
total_target_weight=
|
|
363
|
+
total_target_weight=self.total_expected_target_weight,
|
|
345
364
|
)
|
|
346
365
|
leftovers_orders = self.orders.all()
|
|
347
366
|
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
@@ -351,14 +370,18 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
351
370
|
order.save()
|
|
352
371
|
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
353
372
|
leftovers_orders.delete()
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
373
|
+
self.fix_quantization()
|
|
374
|
+
|
|
375
|
+
def fix_quantization(self):
|
|
376
|
+
if self.orders.exists():
|
|
377
|
+
t_weight = self.get_orders().aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
378
|
+
# we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
|
|
379
|
+
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
380
|
+
biggest_order = self.orders.latest("weighting")
|
|
381
|
+
biggest_order.weighting -= quantize_error
|
|
382
|
+
biggest_order.save()
|
|
383
|
+
|
|
384
|
+
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
362
385
|
if self.rebalancing_model:
|
|
363
386
|
params = {}
|
|
364
387
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -367,7 +390,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
367
390
|
return self.rebalancing_model.get_target_portfolio(
|
|
368
391
|
self.portfolio, self.trade_date, self.value_date, **params
|
|
369
392
|
)
|
|
370
|
-
return self.convert_to_portfolio(use_effective=False)
|
|
393
|
+
return self.convert_to_portfolio(use_effective=False, use_desired_target_weight=use_desired_target_weight)
|
|
371
394
|
|
|
372
395
|
def _get_default_effective_portfolio(self):
|
|
373
396
|
return self.convert_to_portfolio(use_effective=True)
|
|
@@ -377,7 +400,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
377
400
|
target_portfolio: PortfolioDTO | None = None,
|
|
378
401
|
effective_portfolio: PortfolioRole | None = None,
|
|
379
402
|
validate_order: bool = True,
|
|
380
|
-
|
|
403
|
+
use_desired_target_weight: bool = False,
|
|
381
404
|
):
|
|
382
405
|
"""
|
|
383
406
|
Will delete all existing orders and recreate them from the method `create_or_update_trades`
|
|
@@ -387,7 +410,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
387
410
|
# delete all existing orders
|
|
388
411
|
# Get effective and target portfolio
|
|
389
412
|
if not target_portfolio:
|
|
390
|
-
target_portfolio = self._get_default_target_portfolio()
|
|
413
|
+
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
391
414
|
if not effective_portfolio:
|
|
392
415
|
effective_portfolio = self._get_default_effective_portfolio()
|
|
393
416
|
if target_portfolio:
|
|
@@ -395,13 +418,14 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
395
418
|
self.trade_date,
|
|
396
419
|
effective_portfolio=effective_portfolio,
|
|
397
420
|
target_portfolio=target_portfolio,
|
|
398
|
-
total_target_weight=
|
|
421
|
+
total_target_weight=self.total_expected_target_weight,
|
|
399
422
|
)
|
|
400
423
|
if validate_order:
|
|
401
424
|
service.is_valid()
|
|
402
425
|
orders = service.validated_trades
|
|
403
426
|
else:
|
|
404
427
|
orders = service.trades_batch.trades_map.values()
|
|
428
|
+
|
|
405
429
|
for order_dto in orders:
|
|
406
430
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
407
431
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -425,7 +449,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
425
449
|
currency_fx_rate=currency_fx_rate,
|
|
426
450
|
)
|
|
427
451
|
order.price = order.get_price()
|
|
428
|
-
order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
|
|
429
452
|
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
430
453
|
if not order.price:
|
|
431
454
|
order.price = Decimal("0.0")
|
|
@@ -435,6 +458,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
435
458
|
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
436
459
|
self.get_orders().filter(target_weight=0, effective_weight=0).delete()
|
|
437
460
|
|
|
461
|
+
self.fix_quantization()
|
|
462
|
+
|
|
463
|
+
for order in self.get_orders():
|
|
464
|
+
order.order_type = Order.get_type(weighting, order_dto.previous_weight, order_dto.target_weight)
|
|
465
|
+
order.save()
|
|
466
|
+
|
|
438
467
|
def approve_workflow(
|
|
439
468
|
self,
|
|
440
469
|
approve_automatically: bool = True,
|
|
@@ -647,13 +676,16 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
647
676
|
def submit(self, by=None, description=None, **kwargs):
|
|
648
677
|
orders = []
|
|
649
678
|
orders_validation_warnings = []
|
|
650
|
-
|
|
679
|
+
qs = self.get_orders()
|
|
680
|
+
total_target_weight = Decimal("0")
|
|
681
|
+
for order in qs:
|
|
651
682
|
order_warnings = order.submit(
|
|
652
683
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
653
684
|
)
|
|
654
685
|
if order_warnings:
|
|
655
686
|
orders_validation_warnings.extend(order_warnings)
|
|
656
687
|
orders.append(order)
|
|
688
|
+
total_target_weight += order._target_weight
|
|
657
689
|
|
|
658
690
|
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
659
691
|
|
|
@@ -663,6 +695,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
663
695
|
estimated_cash_position._build_dto()
|
|
664
696
|
)
|
|
665
697
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
698
|
+
self.total_cash_weight = Decimal("1") - total_target_weight
|
|
666
699
|
return orders_validation_warnings
|
|
667
700
|
|
|
668
701
|
def can_submit(self):
|
|
@@ -205,7 +205,7 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
205
205
|
if estimated_shares:
|
|
206
206
|
self.shares = estimated_shares
|
|
207
207
|
if self.id:
|
|
208
|
-
self.
|
|
208
|
+
self.set_type()
|
|
209
209
|
super().save(*args, **kwargs)
|
|
210
210
|
|
|
211
211
|
@classmethod
|
|
@@ -224,6 +224,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
224
224
|
else:
|
|
225
225
|
return Order.Type.SELL
|
|
226
226
|
|
|
227
|
+
def set_type(self):
|
|
228
|
+
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
229
|
+
|
|
227
230
|
def get_price(self) -> Decimal:
|
|
228
231
|
try:
|
|
229
232
|
return self.underlying_instrument.get_price(self.value_date)
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -38,6 +38,7 @@ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
|
38
38
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
39
39
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
40
40
|
|
|
41
|
+
from ..constants import EQUITY_TYPE_KEYS
|
|
41
42
|
from . import ProductGroup
|
|
42
43
|
|
|
43
44
|
logger = logging.getLogger("pms")
|
|
@@ -337,8 +338,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
337
338
|
weights = self.get_weights(val_date)
|
|
338
339
|
return_date = (val_date + BDay(1)).date()
|
|
339
340
|
returns = self.builder.returns
|
|
340
|
-
if
|
|
341
|
-
|
|
341
|
+
if (
|
|
342
|
+
return_date <= date.today()
|
|
343
|
+
and pd.Timestamp(return_date) not in returns.index
|
|
344
|
+
or not set(weights.keys()).issubset(set(returns.columns))
|
|
345
|
+
):
|
|
346
|
+
returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl)
|
|
342
347
|
if pd.Timestamp(return_date) not in returns.index:
|
|
343
348
|
raise ValueError()
|
|
344
349
|
returns = returns.loc[:return_date, :]
|
|
@@ -507,7 +512,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
507
512
|
val_date=val_date,
|
|
508
513
|
exclude_cash=True,
|
|
509
514
|
exclude_index=True,
|
|
510
|
-
extra_filter_parameters={"
|
|
515
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
511
516
|
**kwargs,
|
|
512
517
|
)
|
|
513
518
|
if not df.empty:
|
|
@@ -520,7 +525,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
520
525
|
val_date=val_date,
|
|
521
526
|
exclude_cash=True,
|
|
522
527
|
exclude_index=True,
|
|
523
|
-
extra_filter_parameters={"
|
|
528
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
524
529
|
**kwargs,
|
|
525
530
|
)
|
|
526
531
|
if not df.empty:
|
|
@@ -592,7 +597,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
592
597
|
) -> pd.DataFrame:
|
|
593
598
|
qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
|
|
594
599
|
if only_equity:
|
|
595
|
-
qs = qs.filter(
|
|
600
|
+
qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
|
|
596
601
|
qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
|
|
597
602
|
df = Portfolio.get_contribution_df(
|
|
598
603
|
qs.select_related("underlying_instrument").values_list(
|
|
@@ -725,7 +730,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
725
730
|
effective_portfolio_date = (val_date - BDay(1)).date()
|
|
726
731
|
with suppress(ValueError):
|
|
727
732
|
if not analytic_portfolio:
|
|
728
|
-
analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date)
|
|
733
|
+
analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
|
|
729
734
|
for instrument in self.pms_instruments:
|
|
730
735
|
# we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
|
|
731
736
|
last_price = instrument.get_latest_price(effective_portfolio_date)
|
|
@@ -839,11 +844,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
839
844
|
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
|
840
845
|
self.builder.schedule_metric_computation()
|
|
841
846
|
|
|
842
|
-
def load_builder_returns(self, from_date: date, to_date: date) -> pd.DataFrame:
|
|
847
|
+
def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
|
|
843
848
|
instruments_ids = list(self.get_weights(from_date).keys())
|
|
844
849
|
for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
|
|
845
850
|
instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
|
|
846
|
-
self.builder.load_returns(
|
|
851
|
+
self.builder.load_returns(
|
|
852
|
+
set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
|
|
853
|
+
)
|
|
847
854
|
return self.builder.returns
|
|
848
855
|
|
|
849
856
|
def get_lookthrough_positions(
|
|
@@ -928,6 +935,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
928
935
|
)
|
|
929
936
|
self.builder.add(
|
|
930
937
|
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
938
|
+
infer_underlying_quote_price=True,
|
|
931
939
|
)
|
|
932
940
|
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
933
941
|
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
wbportfolio/models/products.py
CHANGED
|
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|
|
4
4
|
|
|
5
5
|
from celery import shared_task
|
|
6
6
|
from django.contrib import admin
|
|
7
|
+
from django.contrib.contenttypes.fields import GenericRelation
|
|
7
8
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
|
8
9
|
from django.contrib.postgres.fields import DateRangeField, RangeOperators
|
|
9
10
|
from django.db import models
|
|
@@ -33,6 +34,7 @@ from wbcore.utils.enum import ChoiceEnum
|
|
|
33
34
|
from wbcrm.models.accounts import Account
|
|
34
35
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
35
36
|
from wbfdm.models.instruments.instruments import InstrumentManager, InstrumentType
|
|
37
|
+
from wbreport.models import Report
|
|
36
38
|
|
|
37
39
|
from wbportfolio.models.portfolio_relationship import InstrumentPortfolioThroughModel
|
|
38
40
|
|
|
@@ -198,6 +200,8 @@ class FeeProductPercentage(models.Model):
|
|
|
198
200
|
|
|
199
201
|
|
|
200
202
|
class Product(PMSInstrumentAbstractModel):
|
|
203
|
+
reports = GenericRelation(Report)
|
|
204
|
+
|
|
201
205
|
share_price = models.PositiveIntegerField(
|
|
202
206
|
default=100,
|
|
203
207
|
verbose_name="Share Price",
|
|
@@ -344,6 +348,11 @@ class Product(PMSInstrumentAbstractModel):
|
|
|
344
348
|
|
|
345
349
|
self.is_managed = True
|
|
346
350
|
|
|
351
|
+
def save(self, *args, **kwargs):
|
|
352
|
+
super().save(*args, **kwargs)
|
|
353
|
+
if self.delisted_date and self.delisted_date <= date.today():
|
|
354
|
+
self.reports.update(is_active=False)
|
|
355
|
+
|
|
347
356
|
def get_title(self):
|
|
348
357
|
if self.parent:
|
|
349
358
|
return f"{self.parent.name} ({self.name})"
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from decimal import Decimal
|
|
2
|
-
|
|
3
1
|
from django.contrib.messages import warning
|
|
4
2
|
from django.core.exceptions import ValidationError
|
|
5
3
|
from rest_framework.reverse import reverse
|
|
@@ -26,25 +24,12 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
26
24
|
queryset=Portfolio.objects.all(), write_only=True, required=False
|
|
27
25
|
)
|
|
28
26
|
_target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
|
|
29
|
-
total_cash_weight = wb_serializers.DecimalField(
|
|
30
|
-
default=0,
|
|
31
|
-
decimal_places=4,
|
|
32
|
-
max_digits=5,
|
|
33
|
-
write_only=True,
|
|
34
|
-
required=False,
|
|
35
|
-
precision=4,
|
|
36
|
-
percent=True,
|
|
37
|
-
label="Target Cash",
|
|
38
|
-
help_text="Enter the desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
27
|
trade_date = wb_serializers.DateField(
|
|
42
28
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
43
29
|
)
|
|
44
30
|
|
|
45
31
|
def create(self, validated_data):
|
|
46
32
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
47
|
-
total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
|
|
48
33
|
rebalancing_model = validated_data.get("rebalancing_model", None)
|
|
49
34
|
if request := self.context.get("request"):
|
|
50
35
|
validated_data["creator"] = request.user.profile
|
|
@@ -59,9 +44,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
59
44
|
)
|
|
60
45
|
|
|
61
46
|
try:
|
|
62
|
-
obj.reset_orders(
|
|
63
|
-
target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
|
|
64
|
-
)
|
|
47
|
+
obj.reset_orders(target_portfolio=target_portfolio_dto)
|
|
65
48
|
except ValidationError as e:
|
|
66
49
|
if request := self.context.get("request"):
|
|
67
50
|
warning(request, str(e), extra_tags="auto_close=0")
|
|
@@ -86,6 +69,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
86
69
|
class Meta:
|
|
87
70
|
model = OrderProposal
|
|
88
71
|
only_fsm_transition_on_instance = True
|
|
72
|
+
percent_fields = ["total_cash_weight"]
|
|
89
73
|
fields = (
|
|
90
74
|
"id",
|
|
91
75
|
"trade_date",
|
wbportfolio/tests/conftest.py
CHANGED
|
@@ -70,7 +70,7 @@ from wbportfolio.factories import (
|
|
|
70
70
|
RebalancerFactory,
|
|
71
71
|
RebalancingModelFactory
|
|
72
72
|
)
|
|
73
|
-
|
|
73
|
+
from wbreport.factories import ReportFactory, ReportAssetFactory, ReportVersionFactory, ReportClassFactory, ReportCategoryFactory
|
|
74
74
|
from wbcore.tests.conftest import * # isort:skip
|
|
75
75
|
|
|
76
76
|
register(AccountFactory)
|
|
@@ -147,7 +147,11 @@ register(DailyPortfolioCashFlowFactory)
|
|
|
147
147
|
|
|
148
148
|
register(AccountReconciliationFactory)
|
|
149
149
|
register(AccountReconciliationLineFactory)
|
|
150
|
-
|
|
150
|
+
register(ReportFactory)
|
|
151
|
+
register(ReportAssetFactory)
|
|
152
|
+
register(ReportVersionFactory)
|
|
153
|
+
register(ReportClassFactory)
|
|
154
|
+
register(ReportCategoryFactory)
|
|
151
155
|
|
|
152
156
|
pre_migrate.connect(app_pre_migration, sender=apps.get_app_config("wbportfolio"))
|
|
153
157
|
from .signals import * # noqa: F401
|