wbportfolio 1.54.20__py2.py3-none-any.whl → 1.54.22__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/import_export/parsers/vontobel/valuation_api.py +4 -1
- wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
- wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
- wbportfolio/models/asset.py +4 -141
- wbportfolio/models/builder.py +260 -0
- wbportfolio/models/orders/order_proposals.py +39 -33
- wbportfolio/models/orders/orders.py +18 -21
- wbportfolio/models/portfolio.py +118 -235
- wbportfolio/serializers/orders/order_proposals.py +1 -0
- wbportfolio/tests/models/orders/test_order_proposals.py +16 -1
- wbportfolio/tests/models/test_portfolios.py +34 -121
- wbportfolio/viewsets/charts/assets.py +2 -2
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +6 -5
- wbportfolio/viewsets/orders/orders.py +1 -1
- wbportfolio/viewsets/positions.py +3 -2
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.22.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.22.dist-info}/RECORD +20 -16
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.22.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.22.dist-info}/licenses/LICENSE +0 -0
|
@@ -38,7 +38,13 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
38
38
|
help_text="The number of shares that were traded.",
|
|
39
39
|
verbose_name="Shares",
|
|
40
40
|
)
|
|
41
|
-
|
|
41
|
+
desired_target_weight = models.DecimalField(
|
|
42
|
+
max_digits=9,
|
|
43
|
+
decimal_places=ORDER_WEIGHTING_PRECISION,
|
|
44
|
+
default=Decimal(0),
|
|
45
|
+
help_text="Desired Target Weight (for compliance and audit)",
|
|
46
|
+
verbose_name="Desired Target Weight",
|
|
47
|
+
)
|
|
42
48
|
weighting = models.DecimalField(
|
|
43
49
|
max_digits=9,
|
|
44
50
|
decimal_places=ORDER_WEIGHTING_PRECISION,
|
|
@@ -64,6 +70,7 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
64
70
|
|
|
65
71
|
def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
|
|
66
72
|
warnings = []
|
|
73
|
+
|
|
67
74
|
# 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
|
|
68
75
|
if self.order_proposal and not self.portfolio.only_weighting:
|
|
69
76
|
shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
|
|
@@ -77,11 +84,19 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
77
84
|
self.shares = shares
|
|
78
85
|
if portfolio_total_asset_value:
|
|
79
86
|
self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
|
|
80
|
-
|
|
87
|
+
if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
|
|
88
|
+
warnings.append(
|
|
89
|
+
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})"
|
|
90
|
+
)
|
|
91
|
+
self.shares = Decimal("0")
|
|
92
|
+
self.weighting = Decimal("0")
|
|
81
93
|
if not self.price:
|
|
82
94
|
warnings.append(f"No price for {self.underlying_instrument.computed_str}")
|
|
83
|
-
if
|
|
95
|
+
if (
|
|
96
|
+
not self.underlying_instrument.is_cash and self._target_weight < -1e-8
|
|
97
|
+
): # any value below -1e8 will be considered zero
|
|
84
98
|
warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
|
|
99
|
+
self.desired_target_weight = self._target_weight
|
|
85
100
|
return warnings
|
|
86
101
|
|
|
87
102
|
@property
|
|
@@ -209,24 +224,6 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
209
224
|
else:
|
|
210
225
|
return Order.Type.SELL
|
|
211
226
|
|
|
212
|
-
def get_asset(self) -> AssetPosition:
|
|
213
|
-
asset = AssetPosition(
|
|
214
|
-
underlying_quote=self.underlying_instrument,
|
|
215
|
-
portfolio_created=None,
|
|
216
|
-
portfolio=self.portfolio,
|
|
217
|
-
date=self.value_date,
|
|
218
|
-
initial_currency_fx_rate=self.currency_fx_rate,
|
|
219
|
-
weighting=self._target_weight,
|
|
220
|
-
initial_price=self.price,
|
|
221
|
-
initial_shares=None,
|
|
222
|
-
asset_valuation_date=self.value_date,
|
|
223
|
-
currency=self.currency,
|
|
224
|
-
is_estimated=False,
|
|
225
|
-
)
|
|
226
|
-
asset.set_weighting(self._target_weight)
|
|
227
|
-
asset.pre_save()
|
|
228
|
-
return asset
|
|
229
|
-
|
|
230
227
|
def get_price(self) -> Decimal:
|
|
231
228
|
try:
|
|
232
229
|
return self.underlying_instrument.get_price(self.value_date)
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -3,34 +3,31 @@ from contextlib import suppress
|
|
|
3
3
|
from datetime import date, timedelta
|
|
4
4
|
from decimal import Decimal
|
|
5
5
|
from math import isclose
|
|
6
|
-
from typing import TYPE_CHECKING, Any, Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generator, Iterable
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import pandas as pd
|
|
10
10
|
from celery import shared_task
|
|
11
|
-
from django.contrib.contenttypes.models import ContentType
|
|
12
11
|
from django.contrib.postgres.fields import DateRangeField
|
|
12
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
13
13
|
from django.db import models
|
|
14
|
-
from django.db.models import
|
|
15
|
-
from django.db.models.functions import Coalesce
|
|
14
|
+
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
|
|
16
15
|
from django.db.models.signals import post_save
|
|
17
16
|
from django.dispatch import receiver
|
|
18
17
|
from django.utils import timezone
|
|
19
18
|
from django.utils.functional import cached_property
|
|
20
19
|
from pandas._libs.tslibs.offsets import BDay
|
|
21
|
-
from
|
|
22
|
-
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
20
|
+
from wbcore.contrib.currency.models import Currency
|
|
23
21
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
24
22
|
from wbcore.models import WBModel
|
|
25
23
|
from wbcore.utils.importlib import import_from_dotted_path
|
|
26
24
|
from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
|
|
27
|
-
from wbfdm.contrib.metric.tasks import compute_metrics_as_task
|
|
28
|
-
from wbfdm.enums import MarketData
|
|
29
25
|
from wbfdm.models import Instrument, InstrumentType
|
|
30
26
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
31
27
|
from wbfdm.signals import investable_universe_updated
|
|
32
28
|
|
|
33
|
-
from wbportfolio.models.asset import AssetPosition
|
|
29
|
+
from wbportfolio.models.asset import AssetPosition
|
|
30
|
+
from wbportfolio.models.builder import AssetPositionBuilder
|
|
34
31
|
from wbportfolio.models.indexes import Index
|
|
35
32
|
from wbportfolio.models.portfolio_relationship import (
|
|
36
33
|
InstrumentPortfolioThroughModel,
|
|
@@ -41,83 +38,16 @@ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
|
41
38
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
42
39
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
43
40
|
|
|
41
|
+
from ..constants import EQUITY_TYPE_KEYS
|
|
44
42
|
from . import ProductGroup
|
|
45
|
-
from .exceptions import InvalidAnalyticPortfolio
|
|
46
43
|
|
|
47
44
|
logger = logging.getLogger("pms")
|
|
48
45
|
if TYPE_CHECKING:
|
|
49
|
-
|
|
46
|
+
pass
|
|
50
47
|
|
|
51
48
|
MARKET_HOLIDAY_MAX_DURATION = 15
|
|
52
49
|
|
|
53
50
|
|
|
54
|
-
def get_returns(
|
|
55
|
-
instrument_ids: list[int],
|
|
56
|
-
from_date: date,
|
|
57
|
-
to_date: date,
|
|
58
|
-
to_currency: Currency | None = None,
|
|
59
|
-
use_dl: bool = False,
|
|
60
|
-
) -> tuple[dict[date, dict[int, float]], pd.DataFrame]:
|
|
61
|
-
"""
|
|
62
|
-
Utility methods to get instrument returns for a given date range
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
from_date: date range lower bound
|
|
66
|
-
to_date: date range upper bound
|
|
67
|
-
to_currency: currency to use for returns
|
|
68
|
-
use_dl: whether to get data straight from the dataloader or use the internal table
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
Return a tuple of the raw prices and the returns dataframe
|
|
72
|
-
"""
|
|
73
|
-
if use_dl:
|
|
74
|
-
kwargs = dict(from_date=from_date, to_date=to_date, values=[MarketData.CLOSE], apply_fx_rate=False)
|
|
75
|
-
if to_currency:
|
|
76
|
-
kwargs["target_currency"] = to_currency.key
|
|
77
|
-
df = pd.DataFrame(Instrument.objects.filter(id__in=instrument_ids).dl.market_data(**kwargs))
|
|
78
|
-
if df.empty:
|
|
79
|
-
raise InvalidAnalyticPortfolio()
|
|
80
|
-
df = df[["instrument_id", "fx_rate", "close", "valuation_date"]]
|
|
81
|
-
else:
|
|
82
|
-
if to_currency:
|
|
83
|
-
fx_rate = Coalesce(
|
|
84
|
-
CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency),
|
|
85
|
-
Decimal("1"),
|
|
86
|
-
)
|
|
87
|
-
else:
|
|
88
|
-
fx_rate = Value(Decimal("1"))
|
|
89
|
-
# annotate fx rate only if the price is not calculated, in that case we assume the instrument is not tradable and we set a forex of None (to be fast forward filled)
|
|
90
|
-
prices = InstrumentPrice.objects.filter(
|
|
91
|
-
instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
|
|
92
|
-
).annotate(fx_rate=Case(When(calculated=False, then=fx_rate), default=None))
|
|
93
|
-
df = pd.DataFrame(
|
|
94
|
-
prices.filter_only_valid_prices().values_list("instrument", "fx_rate", "net_value", "date"),
|
|
95
|
-
columns=["instrument_id", "fx_rate", "close", "valuation_date"],
|
|
96
|
-
)
|
|
97
|
-
if df.empty:
|
|
98
|
-
raise InvalidAnalyticPortfolio()
|
|
99
|
-
df = (
|
|
100
|
-
df.pivot_table(index="valuation_date", columns="instrument_id", values=["fx_rate", "close"], dropna=False)
|
|
101
|
-
.astype(float)
|
|
102
|
-
.sort_index()
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
|
|
106
|
-
df = df.reindex(ts)
|
|
107
|
-
df = df.ffill()
|
|
108
|
-
df.index = pd.to_datetime(df.index)
|
|
109
|
-
prices_df = df["close"]
|
|
110
|
-
|
|
111
|
-
if "fx_rate" in df.columns:
|
|
112
|
-
fx_rate_df = df["fx_rate"].fillna(1.0)
|
|
113
|
-
else:
|
|
114
|
-
fx_rate_df = pd.DataFrame(np.ones(prices_df.shape), index=prices_df.index, columns=prices_df.columns)
|
|
115
|
-
returns = prices_to_returns(fx_rate_df * prices_df, drop_inceptions_nan=False, fill_nan=True)
|
|
116
|
-
return {ts.date(): row for ts, row in prices_df.to_dict("index").items()}, returns.replace(
|
|
117
|
-
[np.inf, -np.inf, np.nan], 0
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
|
|
121
51
|
class DefaultPortfolioQueryset(QuerySet):
|
|
122
52
|
def filter_invested_at_date(self, val_date: date) -> QuerySet:
|
|
123
53
|
"""
|
|
@@ -217,6 +147,7 @@ class PortfolioPortfolioThroughModel(models.Model):
|
|
|
217
147
|
|
|
218
148
|
class Portfolio(DeleteToDisableMixin, WBModel):
|
|
219
149
|
assets: models.QuerySet[AssetPosition]
|
|
150
|
+
builder: AssetPositionBuilder
|
|
220
151
|
|
|
221
152
|
name = models.CharField(
|
|
222
153
|
max_length=255,
|
|
@@ -305,6 +236,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
305
236
|
objects = DefaultPortfolioManager()
|
|
306
237
|
tracked_objects = ActiveTrackedPortfolioManager()
|
|
307
238
|
|
|
239
|
+
def __init__(self, *args, **kwargs):
|
|
240
|
+
self.builder = AssetPositionBuilder(self)
|
|
241
|
+
super().__init__(*args, **kwargs)
|
|
242
|
+
|
|
308
243
|
@property
|
|
309
244
|
def primary_portfolio(self):
|
|
310
245
|
with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
|
|
@@ -358,7 +293,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
358
293
|
try:
|
|
359
294
|
last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
|
|
360
295
|
last_returns = last_returns.to_dict()
|
|
361
|
-
except
|
|
296
|
+
except ValueError:
|
|
362
297
|
last_returns = {}
|
|
363
298
|
positions = []
|
|
364
299
|
for asset in assets:
|
|
@@ -387,7 +322,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
387
322
|
)
|
|
388
323
|
|
|
389
324
|
def get_analytic_portfolio(
|
|
390
|
-
self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool =
|
|
325
|
+
self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
|
|
391
326
|
) -> AnalyticPortfolio:
|
|
392
327
|
"""
|
|
393
328
|
Return the analytic portfolio associated with this portfolio at the given date
|
|
@@ -402,16 +337,16 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
402
337
|
if not weights:
|
|
403
338
|
weights = self.get_weights(val_date)
|
|
404
339
|
return_date = (val_date + BDay(1)).date()
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return_date
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
)
|
|
340
|
+
returns = self.builder.returns
|
|
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)
|
|
413
347
|
if pd.Timestamp(return_date) not in returns.index:
|
|
414
|
-
raise
|
|
348
|
+
raise ValueError()
|
|
349
|
+
returns = returns.loc[:return_date, :]
|
|
415
350
|
returns = returns.fillna(0) # not sure this is what we want
|
|
416
351
|
return AnalyticPortfolio(
|
|
417
352
|
X=returns,
|
|
@@ -577,7 +512,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
577
512
|
val_date=val_date,
|
|
578
513
|
exclude_cash=True,
|
|
579
514
|
exclude_index=True,
|
|
580
|
-
extra_filter_parameters={"
|
|
515
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
581
516
|
**kwargs,
|
|
582
517
|
)
|
|
583
518
|
if not df.empty:
|
|
@@ -590,7 +525,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
590
525
|
val_date=val_date,
|
|
591
526
|
exclude_cash=True,
|
|
592
527
|
exclude_index=True,
|
|
593
|
-
extra_filter_parameters={"
|
|
528
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
594
529
|
**kwargs,
|
|
595
530
|
)
|
|
596
531
|
if not df.empty:
|
|
@@ -662,7 +597,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
662
597
|
) -> pd.DataFrame:
|
|
663
598
|
qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
|
|
664
599
|
if only_equity:
|
|
665
|
-
qs = qs.filter(
|
|
600
|
+
qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
|
|
666
601
|
qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
|
|
667
602
|
df = Portfolio.get_contribution_df(
|
|
668
603
|
qs.select_related("underlying_instrument").values_list(
|
|
@@ -712,7 +647,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
712
647
|
recompute_weighting: bool = False,
|
|
713
648
|
force_recompute_weighting: bool = False,
|
|
714
649
|
evaluate_rebalancer: bool = True,
|
|
715
|
-
|
|
650
|
+
changed_portfolio: AnalyticPortfolio | None = None,
|
|
716
651
|
broadcast_changes_at_date: bool = True,
|
|
717
652
|
**kwargs,
|
|
718
653
|
):
|
|
@@ -743,7 +678,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
743
678
|
|
|
744
679
|
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
745
680
|
self.estimate_net_asset_values(
|
|
746
|
-
(val_date + BDay(1)).date(),
|
|
681
|
+
(val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
|
|
747
682
|
) # updating weighting in t0 influence nav in t+1
|
|
748
683
|
if evaluate_rebalancer:
|
|
749
684
|
self.evaluate_rebalancing(val_date)
|
|
@@ -760,7 +695,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
760
695
|
val_date,
|
|
761
696
|
recompute_weighting=recompute_weighting,
|
|
762
697
|
force_recompute_weighting=force_recompute_weighting,
|
|
763
|
-
|
|
698
|
+
changed_portfolio=changed_portfolio,
|
|
764
699
|
**kwargs,
|
|
765
700
|
)
|
|
766
701
|
|
|
@@ -771,7 +706,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
771
706
|
type=PortfolioPortfolioThroughModel.Type.PRIMARY,
|
|
772
707
|
portfolio__is_lookthrough=True,
|
|
773
708
|
):
|
|
774
|
-
rel.portfolio.compute_lookthrough(val_date
|
|
709
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
775
710
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
776
711
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
777
712
|
):
|
|
@@ -791,97 +726,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
791
726
|
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
792
727
|
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
793
728
|
|
|
794
|
-
def estimate_net_asset_values(self, val_date: date,
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
},
|
|
729
|
+
def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
|
|
730
|
+
effective_portfolio_date = (val_date - BDay(1)).date()
|
|
731
|
+
with suppress(ValueError):
|
|
732
|
+
if not analytic_portfolio:
|
|
733
|
+
analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
|
|
734
|
+
for instrument in self.pms_instruments:
|
|
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
|
|
736
|
+
last_price = instrument.get_latest_price(effective_portfolio_date)
|
|
737
|
+
if (
|
|
738
|
+
instrument.is_active_at_date(val_date)
|
|
739
|
+
and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
|
|
740
|
+
and last_price
|
|
741
|
+
):
|
|
742
|
+
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
743
|
+
net_asset_value_computation_method = import_from_dotted_path(
|
|
744
|
+
net_asset_value_computation_method_path
|
|
811
745
|
)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
746
|
+
estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
|
|
747
|
+
if estimated_net_asset_value is not None:
|
|
748
|
+
InstrumentPrice.objects.update_or_create(
|
|
749
|
+
instrument=instrument,
|
|
750
|
+
date=val_date,
|
|
751
|
+
calculated=True,
|
|
752
|
+
defaults={
|
|
753
|
+
"gross_value": estimated_net_asset_value,
|
|
754
|
+
"net_value": estimated_net_asset_value,
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
if (
|
|
758
|
+
val_date == instrument.last_price_date
|
|
759
|
+
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
760
|
+
instrument.update_last_valuation_date()
|
|
816
761
|
|
|
817
762
|
def drift_weights(
|
|
818
763
|
self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
|
|
819
|
-
) -> tuple[
|
|
764
|
+
) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
|
|
820
765
|
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
766
|
+
|
|
821
767
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
822
768
|
# Get initial weights
|
|
823
769
|
weights = self.get_weights(start_date) # initial weights
|
|
824
770
|
if not weights:
|
|
825
771
|
previous_date = self.assets.filter(date__lte=start_date).latest("date").date
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
# Get returns and prices data for the whole date range
|
|
830
|
-
instrument_ids = list(weights.keys())
|
|
831
|
-
|
|
832
|
-
prices, returns = get_returns(
|
|
833
|
-
instrument_ids,
|
|
834
|
-
(start_date - BDay(MARKET_HOLIDAY_MAX_DURATION)).date(),
|
|
835
|
-
end_date,
|
|
836
|
-
to_currency=self.currency,
|
|
837
|
-
use_dl=True,
|
|
838
|
-
)
|
|
839
|
-
# Get raw prices to speed up asset position creation
|
|
840
|
-
# Instantiate the position iterator with the initial weights
|
|
841
|
-
positions = AssetPositionIterator(self, prices=prices)
|
|
772
|
+
_, weights = next(self.drift_weights(previous_date, start_date))
|
|
773
|
+
|
|
842
774
|
last_order_proposal = None
|
|
843
775
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
844
776
|
to_date = to_date_ts.date()
|
|
845
777
|
to_is_active = self.is_active_at_date(to_date)
|
|
846
778
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
847
|
-
|
|
779
|
+
order_proposal = None
|
|
848
780
|
try:
|
|
849
|
-
last_returns = returns.loc[[to_date_ts], :]
|
|
781
|
+
last_returns = self.builder.returns.loc[[to_date_ts], :]
|
|
850
782
|
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
851
783
|
drifted_weights = analytic_portfolio.get_next_weights()
|
|
852
784
|
except KeyError: # if no return for that date, we break and continue
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
PositionDTO(
|
|
858
|
-
date=to_date,
|
|
859
|
-
underlying_instrument=i,
|
|
860
|
-
weighting=Decimal(w),
|
|
861
|
-
daily_return=Decimal(last_returns.iloc[-1][i]),
|
|
862
|
-
)
|
|
863
|
-
for i, w in weights.items()
|
|
864
|
-
]
|
|
785
|
+
break
|
|
786
|
+
try:
|
|
787
|
+
order_proposal = self.order_proposals.get(
|
|
788
|
+
trade_date=to_date, rebalancing_model__isnull=True, status="APPROVED"
|
|
865
789
|
)
|
|
866
|
-
|
|
790
|
+
except ObjectDoesNotExist:
|
|
791
|
+
if rebalancer and rebalancer.is_valid(to_date):
|
|
792
|
+
effective_portfolio = PortfolioDTO(
|
|
793
|
+
positions=[
|
|
794
|
+
PositionDTO(
|
|
795
|
+
date=to_date,
|
|
796
|
+
underlying_instrument=i,
|
|
797
|
+
weighting=Decimal(w),
|
|
798
|
+
daily_return=Decimal(last_returns.iloc[-1][i]),
|
|
799
|
+
)
|
|
800
|
+
for i, w in weights.items()
|
|
801
|
+
]
|
|
802
|
+
)
|
|
803
|
+
order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
|
|
804
|
+
if order_proposal:
|
|
805
|
+
last_order_proposal = order_proposal
|
|
867
806
|
if stop_at_rebalancing:
|
|
868
807
|
break
|
|
869
808
|
next_weights = {
|
|
870
809
|
trade.underlying_instrument.id: float(trade._target_weight)
|
|
871
|
-
for trade in
|
|
810
|
+
for trade in order_proposal.get_orders()
|
|
872
811
|
}
|
|
873
|
-
|
|
812
|
+
yield to_date, next_weights
|
|
874
813
|
else:
|
|
875
814
|
next_weights = drifted_weights
|
|
876
815
|
if to_is_active:
|
|
877
|
-
|
|
816
|
+
yield to_date, next_weights
|
|
878
817
|
else:
|
|
879
|
-
|
|
880
|
-
|
|
818
|
+
yield (
|
|
819
|
+
to_date,
|
|
820
|
+
{underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
|
|
881
821
|
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
882
822
|
break
|
|
883
823
|
weights = next_weights
|
|
884
|
-
return
|
|
824
|
+
return last_order_proposal
|
|
885
825
|
|
|
886
826
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
887
827
|
"""
|
|
@@ -897,10 +837,21 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
897
837
|
if (
|
|
898
838
|
not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
|
|
899
839
|
): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
|
|
900
|
-
|
|
901
|
-
self.
|
|
902
|
-
|
|
903
|
-
)
|
|
840
|
+
self.load_builder_returns(from_date, to_date)
|
|
841
|
+
for pos_date, weights in self.drift_weights(from_date, to_date):
|
|
842
|
+
self.builder.add((pos_date, weights))
|
|
843
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
844
|
+
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
|
845
|
+
self.builder.schedule_metric_computation()
|
|
846
|
+
|
|
847
|
+
def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
|
|
848
|
+
instruments_ids = list(self.get_weights(from_date).keys())
|
|
849
|
+
for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
|
|
850
|
+
instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
|
|
851
|
+
self.builder.load_returns(
|
|
852
|
+
set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
|
|
853
|
+
)
|
|
854
|
+
return self.builder.returns
|
|
904
855
|
|
|
905
856
|
def get_lookthrough_positions(
|
|
906
857
|
self,
|
|
@@ -970,12 +921,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
970
921
|
assets = list(self.assets.filter(date=val_date))
|
|
971
922
|
return assets
|
|
972
923
|
|
|
973
|
-
def compute_lookthrough(self, from_date: date, to_date: date | None = None
|
|
924
|
+
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
974
925
|
if not self.primary_portfolio or not self.is_lookthrough:
|
|
975
926
|
raise ValueError(
|
|
976
927
|
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
977
928
|
)
|
|
978
|
-
positions = AssetPositionIterator(self)
|
|
979
929
|
if not to_date:
|
|
980
930
|
to_date = from_date
|
|
981
931
|
for from_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
@@ -983,10 +933,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
983
933
|
portfolio_total_asset_value = (
|
|
984
934
|
self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
|
|
985
935
|
)
|
|
986
|
-
|
|
936
|
+
self.builder.add(
|
|
987
937
|
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
988
938
|
)
|
|
989
|
-
self.bulk_create_positions(
|
|
939
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
940
|
+
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
|
941
|
+
self.builder.schedule_metric_computation()
|
|
990
942
|
|
|
991
943
|
def update_preferred_classification_per_instrument(self):
|
|
992
944
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -1043,64 +995,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1043
995
|
def get_representation_label_key(cls):
|
|
1044
996
|
return "{{name}}"
|
|
1045
997
|
|
|
1046
|
-
def bulk_create_positions(
|
|
1047
|
-
self,
|
|
1048
|
-
positions: AssetPositionIterator,
|
|
1049
|
-
delete_leftovers: bool = False,
|
|
1050
|
-
force_save: bool = False,
|
|
1051
|
-
compute_metrics: bool = False,
|
|
1052
|
-
**kwargs,
|
|
1053
|
-
):
|
|
1054
|
-
if positions:
|
|
1055
|
-
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
1056
|
-
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
1057
|
-
# change completely the trades of a portfolio model and drift it.
|
|
1058
|
-
|
|
1059
|
-
dates = positions.get_dates()
|
|
1060
|
-
self.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
1061
|
-
|
|
1062
|
-
if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
1063
|
-
leftover_positions_ids = list(
|
|
1064
|
-
self.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
1065
|
-
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
1066
|
-
positions_list = list(positions)
|
|
1067
|
-
logger.info(
|
|
1068
|
-
f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
|
|
1069
|
-
)
|
|
1070
|
-
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
1071
|
-
positions_list,
|
|
1072
|
-
update_fields=[
|
|
1073
|
-
"weighting",
|
|
1074
|
-
"initial_price",
|
|
1075
|
-
"initial_currency_fx_rate",
|
|
1076
|
-
"initial_shares",
|
|
1077
|
-
"currency_fx_rate_instrument_to_usd",
|
|
1078
|
-
"currency_fx_rate_portfolio_to_usd",
|
|
1079
|
-
"underlying_quote_price",
|
|
1080
|
-
"portfolio",
|
|
1081
|
-
"portfolio_created",
|
|
1082
|
-
"underlying_instrument",
|
|
1083
|
-
],
|
|
1084
|
-
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
1085
|
-
update_conflicts=True,
|
|
1086
|
-
batch_size=10000,
|
|
1087
|
-
)
|
|
1088
|
-
if delete_leftovers:
|
|
1089
|
-
objs_ids = list(map(lambda x: x.id, objs))
|
|
1090
|
-
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
1091
|
-
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
1092
|
-
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
1093
|
-
if compute_metrics and self.is_tracked:
|
|
1094
|
-
for val_date in dates:
|
|
1095
|
-
compute_metrics_as_task.delay(
|
|
1096
|
-
val_date,
|
|
1097
|
-
basket_id=self.id,
|
|
1098
|
-
basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id,
|
|
1099
|
-
)
|
|
1100
|
-
for update_date, changed_weights in positions.get_weights().items():
|
|
1101
|
-
kwargs.pop("changed_weights", None)
|
|
1102
|
-
self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
|
|
1103
|
-
|
|
1104
998
|
@classmethod
|
|
1105
999
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
1106
1000
|
if isinstance(portfolio_data, int):
|
|
@@ -1253,20 +1147,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1253
1147
|
return portfolio
|
|
1254
1148
|
|
|
1255
1149
|
|
|
1256
|
-
def default_estimate_net_value(
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
portfolio: Portfolio = instrument.portfolio
|
|
1260
|
-
previous_val_date = (val_date - BDay(1)).date()
|
|
1261
|
-
if not weights:
|
|
1262
|
-
weights = portfolio.get_weights(previous_val_date)
|
|
1263
|
-
# 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
|
|
1264
|
-
if weights and (last_price := instrument.get_latest_price(previous_val_date)):
|
|
1265
|
-
with suppress(
|
|
1266
|
-
IndexError, InvalidAnalyticPortfolio
|
|
1267
|
-
): # we silent any indexerror introduced by no returns for the past days
|
|
1268
|
-
analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
|
|
1269
|
-
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1150
|
+
def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
|
|
1151
|
+
with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
|
|
1152
|
+
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1270
1153
|
|
|
1271
1154
|
|
|
1272
1155
|
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
@@ -383,7 +383,7 @@ class TestOrderProposal:
|
|
|
383
383
|
"""
|
|
384
384
|
Ensure replaying order proposals correctly calls drift_weights for each period.
|
|
385
385
|
"""
|
|
386
|
-
mock_fct.return_value =
|
|
386
|
+
mock_fct.return_value = iter([])
|
|
387
387
|
|
|
388
388
|
# Create approved order proposals for testing
|
|
389
389
|
tp0 = order_proposal_factory.create(status=OrderProposal.Status.APPROVED)
|
|
@@ -672,3 +672,18 @@ class TestOrderProposal:
|
|
|
672
672
|
assert Order.objects.get(
|
|
673
673
|
order_proposal=draft_tp, underlying_instrument=trade.underlying_instrument
|
|
674
674
|
).weighting == Decimal("0")
|
|
675
|
+
|
|
676
|
+
def test_order_submit_bellow_minimum_allowed_order_value(self, order_factory):
|
|
677
|
+
order = order_factory.create(price=Decimal(1), weighting=Decimal(1), shares=Decimal(999))
|
|
678
|
+
order.submit()
|
|
679
|
+
order.save()
|
|
680
|
+
assert order.shares == Decimal(999)
|
|
681
|
+
assert order.weighting == Decimal(1)
|
|
682
|
+
|
|
683
|
+
order.order_proposal.min_order_value = Decimal(1000)
|
|
684
|
+
order.order_proposal.save()
|
|
685
|
+
|
|
686
|
+
order.submit()
|
|
687
|
+
order.save()
|
|
688
|
+
assert order.shares == Decimal(0)
|
|
689
|
+
assert order.weighting == Decimal(0)
|