wbportfolio 1.49.9__py2.py3-none-any.whl → 1.49.10__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/import_export/parsers/natixis/fees.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/customer_trade_slk.py +31 -17
- wbportfolio/models/asset.py +157 -0
- wbportfolio/models/portfolio.py +180 -245
- wbportfolio/models/portfolio_cash_flow.py +5 -5
- wbportfolio/models/roles.py +1 -5
- wbportfolio/models/transactions/trade_proposals.py +18 -4
- wbportfolio/models/transactions/trades.py +1 -1
- wbportfolio/tests/models/test_portfolios.py +50 -65
- wbportfolio/tests/models/test_products.py +0 -5
- wbportfolio/tests/models/transactions/test_trade_proposals.py +17 -5
- wbportfolio/viewsets/portfolios.py +2 -2
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.10.dist-info}/METADATA +1 -1
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.10.dist-info}/RECORD +16 -16
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.10.dist-info}/WHEEL +0 -0
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.10.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/portfolio.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from collections import defaultdict
|
|
3
2
|
from contextlib import suppress
|
|
4
3
|
from datetime import date, timedelta
|
|
5
4
|
from decimal import Decimal
|
|
6
5
|
from math import isclose
|
|
7
|
-
from typing import Any, Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Iterable
|
|
8
7
|
|
|
9
8
|
import numpy as np
|
|
10
9
|
import pandas as pd
|
|
@@ -19,6 +18,7 @@ from django.db.models import (
|
|
|
19
18
|
Q,
|
|
20
19
|
QuerySet,
|
|
21
20
|
Sum,
|
|
21
|
+
Value,
|
|
22
22
|
)
|
|
23
23
|
from django.db.models.signals import post_save
|
|
24
24
|
from django.dispatch import receiver
|
|
@@ -36,7 +36,7 @@ from wbfdm.models import Instrument, InstrumentType
|
|
|
36
36
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
37
37
|
from wbfdm.signals import investable_universe_updated
|
|
38
38
|
|
|
39
|
-
from wbportfolio.models.asset import AssetPosition
|
|
39
|
+
from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
|
|
40
40
|
from wbportfolio.models.indexes import Index
|
|
41
41
|
from wbportfolio.models.portfolio_relationship import (
|
|
42
42
|
InstrumentPortfolioThroughModel,
|
|
@@ -50,68 +50,56 @@ from . import ProductGroup
|
|
|
50
50
|
from .exceptions import InvalidAnalyticPortfolio
|
|
51
51
|
|
|
52
52
|
logger = logging.getLogger("pms")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
underlying_instrument=None,
|
|
104
|
-
)
|
|
105
|
-
position.pre_save(
|
|
106
|
-
infer_underlying_quote_price=self.infer_underlying_quote_price
|
|
107
|
-
) # 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
|
|
108
|
-
return position
|
|
109
|
-
|
|
110
|
-
def convert(self, positions: dict[date, dict[int, float]]) -> Iterable[AssetPosition]:
|
|
111
|
-
for val_date, weights in positions.items():
|
|
112
|
-
yield from filter(
|
|
113
|
-
lambda x: x, map(lambda row: self.dict_to_model(val_date, row[0], row[1]), weights.items())
|
|
114
|
-
)
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from wbportfolio.models.transactions.trade_proposals import TradeProposal
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_dict(df: pd.DataFrame) -> dict[date, dict[int, float]]:
|
|
58
|
+
return {ts.date(): row for ts, row in df.to_dict("index").items()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_returns(
|
|
62
|
+
instrument_ids: list[int],
|
|
63
|
+
from_date: date,
|
|
64
|
+
to_date: date,
|
|
65
|
+
to_currency: Currency | None = None,
|
|
66
|
+
ffill_returns: bool = True,
|
|
67
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
68
|
+
"""
|
|
69
|
+
Utility methods to get instrument returns for a given date range
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
from_date: date range lower bound
|
|
73
|
+
to_date: date range upper bound
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Return a tuple of the returns and the last prices series for conveniance
|
|
77
|
+
"""
|
|
78
|
+
if to_currency:
|
|
79
|
+
fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
|
|
80
|
+
else:
|
|
81
|
+
fx_rate = Value(Decimal(1.0))
|
|
82
|
+
prices = InstrumentPrice.objects.filter(
|
|
83
|
+
instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
|
|
84
|
+
).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
|
|
85
|
+
prices_df = (
|
|
86
|
+
pd.DataFrame(
|
|
87
|
+
prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
88
|
+
columns=["instrument", "price_fx_portfolio", "date"],
|
|
89
|
+
)
|
|
90
|
+
.pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
91
|
+
.astype(float)
|
|
92
|
+
.sort_index()
|
|
93
|
+
)
|
|
94
|
+
if prices_df.empty:
|
|
95
|
+
raise InvalidAnalyticPortfolio()
|
|
96
|
+
ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
|
|
97
|
+
prices_df = prices_df.reindex(ts)
|
|
98
|
+
if ffill_returns:
|
|
99
|
+
prices_df = prices_df.ffill()
|
|
100
|
+
prices_df.index = pd.to_datetime(prices_df.index)
|
|
101
|
+
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
102
|
+
return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df.replace([np.inf, -np.inf, np.nan], None)
|
|
115
103
|
|
|
116
104
|
|
|
117
105
|
class DefaultPortfolioQueryset(QuerySet):
|
|
@@ -185,7 +173,7 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
|
|
|
185
173
|
super()
|
|
186
174
|
.get_queryset()
|
|
187
175
|
.annotate(asset_exists=Exists(AssetPosition.unannotated_objects.filter(portfolio=OuterRef("pk"))))
|
|
188
|
-
.filter(asset_exists=True
|
|
176
|
+
.filter(Q(asset_exists=True) & (Q(is_tracked=True) | Q(is_manageable=True)))
|
|
189
177
|
)
|
|
190
178
|
|
|
191
179
|
|
|
@@ -337,7 +325,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
337
325
|
|
|
338
326
|
@property
|
|
339
327
|
def can_be_rebalanced(self):
|
|
340
|
-
return self.
|
|
328
|
+
return self.is_manageable and not self.is_lookthrough
|
|
341
329
|
|
|
342
330
|
def delete(self, **kwargs):
|
|
343
331
|
super().delete(**kwargs)
|
|
@@ -374,7 +362,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
374
362
|
)
|
|
375
363
|
)
|
|
376
364
|
|
|
377
|
-
def get_analytic_portfolio(
|
|
365
|
+
def get_analytic_portfolio(
|
|
366
|
+
self, val_date: date, weights: dict[int, float] | None = None, **kwargs
|
|
367
|
+
) -> tuple[AnalyticPortfolio, pd.DataFrame]:
|
|
378
368
|
"""
|
|
379
369
|
Return the analytic portfolio associated with this portfolio at the given date
|
|
380
370
|
|
|
@@ -385,9 +375,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
385
375
|
Returns:
|
|
386
376
|
The instantiated analytic portfolio
|
|
387
377
|
"""
|
|
388
|
-
|
|
378
|
+
if not weights:
|
|
379
|
+
weights = self.get_weights(val_date)
|
|
389
380
|
return_date = (val_date + BDay(1)).date()
|
|
390
|
-
returns, prices =
|
|
381
|
+
returns, prices = get_returns(
|
|
382
|
+
list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
|
|
383
|
+
)
|
|
391
384
|
if pd.Timestamp(return_date) not in returns.index:
|
|
392
385
|
raise InvalidAnalyticPortfolio()
|
|
393
386
|
returns = returns.fillna(0) # not sure this is what we want
|
|
@@ -682,6 +675,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
682
675
|
force_recompute_weighting: bool = False,
|
|
683
676
|
compute_metrics: bool = False,
|
|
684
677
|
evaluate_rebalancer: bool = True,
|
|
678
|
+
changed_weights: dict[int, float] | None = None,
|
|
685
679
|
):
|
|
686
680
|
logger.info(f"change at date for {self} at {val_date}")
|
|
687
681
|
|
|
@@ -709,7 +703,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
709
703
|
asset.save()
|
|
710
704
|
|
|
711
705
|
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
712
|
-
self.estimate_net_asset_values(
|
|
706
|
+
self.estimate_net_asset_values(
|
|
707
|
+
(val_date + BDay(1)).date(), weights=changed_weights
|
|
708
|
+
) # updating weighting in t0 influence nav in t+1
|
|
713
709
|
if evaluate_rebalancer:
|
|
714
710
|
self.evaluate_rebalancing(val_date)
|
|
715
711
|
|
|
@@ -731,13 +727,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
731
727
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
732
728
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
|
|
733
729
|
):
|
|
734
|
-
|
|
735
|
-
portfolio_total_asset_value = (
|
|
736
|
-
self.get_total_asset_under_management(val_date) if not lookthrough_portfolio.only_weighting else None
|
|
737
|
-
)
|
|
738
|
-
lookthrough_portfolio.compute_lookthrough(
|
|
739
|
-
val_date, portfolio_total_asset_value=portfolio_total_asset_value
|
|
740
|
-
)
|
|
730
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
741
731
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
742
732
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
743
733
|
):
|
|
@@ -757,14 +747,14 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
757
747
|
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
758
748
|
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
759
749
|
|
|
760
|
-
def estimate_net_asset_values(self, val_date: date):
|
|
750
|
+
def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
|
|
761
751
|
for instrument in self.pms_instruments:
|
|
762
752
|
if instrument.is_active_at_date(val_date) and (
|
|
763
753
|
net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
|
|
764
754
|
):
|
|
765
755
|
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
766
756
|
net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
|
|
767
|
-
estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument)
|
|
757
|
+
estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
|
|
768
758
|
if estimated_net_asset_value is not None:
|
|
769
759
|
InstrumentPrice.objects.update_or_create(
|
|
770
760
|
instrument=instrument,
|
|
@@ -780,40 +770,57 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
780
770
|
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
781
771
|
instrument.update_last_valuation_date()
|
|
782
772
|
|
|
783
|
-
def
|
|
784
|
-
""
|
|
785
|
-
Create the cumulative portfolios between the two given dates and stop at the first rebalancing (if any)
|
|
786
|
-
|
|
787
|
-
Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
|
|
788
|
-
"""
|
|
773
|
+
def drift_weights(self, start_date: date, end_date: date) -> tuple[AssetPositionIterator, "TradeProposal"]:
|
|
774
|
+
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
789
775
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
790
|
-
|
|
776
|
+
# Get initial weights
|
|
791
777
|
weights = self.get_weights(start_date) # initial weights
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
778
|
+
if not weights:
|
|
779
|
+
previous_date = self.assets.filter(date__lte=start_date).latest("date").date
|
|
780
|
+
drifted_positions, _ = self.drift_weights(previous_date, start_date)
|
|
781
|
+
weights = drifted_positions.get_weights()[start_date]
|
|
782
|
+
|
|
783
|
+
# Get returns and prices data for the whole date range
|
|
784
|
+
returns, prices = get_returns(
|
|
785
|
+
list(weights.keys()),
|
|
786
|
+
(start_date - BDay(3)).date(),
|
|
787
|
+
end_date,
|
|
788
|
+
to_currency=self.currency,
|
|
789
|
+
ffill_returns=True,
|
|
790
|
+
)
|
|
791
|
+
# Instantiate the position iterator with the initial weights
|
|
792
|
+
positions = AssetPositionIterator(self, prices=to_dict(prices))
|
|
793
|
+
last_trade_proposal = None
|
|
797
794
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
798
795
|
to_date = to_date_ts.date()
|
|
796
|
+
to_is_active = self.is_active_at_date(to_date)
|
|
799
797
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
800
798
|
if rebalancer and rebalancer.is_valid(to_date):
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
799
|
+
last_trade_proposal = rebalancer.evaluate_rebalancing(to_date)
|
|
800
|
+
# if trade proposal/rebalancing is not approved, we cannot continue the drift
|
|
801
|
+
if last_trade_proposal.status != last_trade_proposal.Status.APPROVED:
|
|
802
|
+
break
|
|
803
|
+
target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
|
|
804
|
+
next_weights = {
|
|
805
|
+
underlying_quote_id: float(pos.weighting)
|
|
806
|
+
for underlying_quote_id, pos in target_portfolio.positions_map.items()
|
|
807
|
+
}
|
|
808
|
+
else:
|
|
809
|
+
try:
|
|
810
|
+
last_returns = returns.loc[[to_date_ts], :]
|
|
811
|
+
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
812
|
+
next_weights = analytic_portfolio.get_next_weights()
|
|
813
|
+
except KeyError: # if no return for that date, we break and continue
|
|
814
|
+
next_weights = weights
|
|
815
|
+
if to_is_active:
|
|
816
|
+
positions.add((to_date, next_weights))
|
|
817
|
+
else:
|
|
818
|
+
positions.add(
|
|
819
|
+
(to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
|
|
820
|
+
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
809
821
|
break
|
|
810
|
-
|
|
811
|
-
positions
|
|
812
|
-
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False)
|
|
813
|
-
if rebalancing_date:
|
|
814
|
-
next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
|
|
815
|
-
|
|
816
|
-
return next_trade_proposal
|
|
822
|
+
weights = next_weights
|
|
823
|
+
return positions, last_trade_proposal
|
|
817
824
|
|
|
818
825
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
819
826
|
"""
|
|
@@ -824,33 +831,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
824
831
|
to_date: The date to create the new portfolio at
|
|
825
832
|
|
|
826
833
|
"""
|
|
827
|
-
from_is_active = self.is_active_at_date(from_date)
|
|
828
|
-
to_is_active = self.is_active_at_date(to_date)
|
|
829
|
-
|
|
830
|
-
def _parse_position(asset: AssetPosition) -> AssetPosition:
|
|
831
|
-
# function to handle the position modification after instantiation
|
|
832
|
-
if from_is_active and not to_is_active:
|
|
833
|
-
asset.weighting = Decimal(0.0)
|
|
834
|
-
asset.initial_shares = AssetPosition.unannotated_objects.filter(
|
|
835
|
-
date=from_date, underlying_quote=asset.underlying_quote, portfolio=self
|
|
836
|
-
).aggregate(sum_shares=Sum("initial_shares"))["sum_shares"]
|
|
837
|
-
return asset
|
|
838
|
-
|
|
839
834
|
# we don't propagate on already imported portfolio by default
|
|
840
835
|
is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
|
|
841
836
|
if (
|
|
842
|
-
|
|
837
|
+
not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
|
|
843
838
|
): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
analytic_portfolio, to_prices = self.get_analytic_portfolio(from_date)
|
|
847
|
-
if not to_prices.empty:
|
|
848
|
-
weights = analytic_portfolio.get_next_weights()
|
|
849
|
-
positions_generator = PositionDictConverter(
|
|
850
|
-
self, to_prices, infer_underlying_quote_price=True
|
|
851
|
-
).convert({to_date: weights})
|
|
852
|
-
positions = list(map(lambda a: _parse_position(a), positions_generator))
|
|
853
|
-
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
839
|
+
positions, _ = self.drift_weights(from_date, to_date)
|
|
840
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
854
841
|
|
|
855
842
|
def get_lookthrough_positions(
|
|
856
843
|
self,
|
|
@@ -922,27 +909,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
922
909
|
assets = self.assets.filter(date=val_date)
|
|
923
910
|
return assets
|
|
924
911
|
|
|
925
|
-
def compute_lookthrough(self,
|
|
912
|
+
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
926
913
|
if not self.primary_portfolio or not self.is_lookthrough:
|
|
927
914
|
raise ValueError(
|
|
928
915
|
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
929
916
|
)
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
if not self.primary_portfolio or not self.is_lookthrough:
|
|
936
|
-
raise ValueError(
|
|
937
|
-
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
938
|
-
)
|
|
939
|
-
positions = []
|
|
940
|
-
for val_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
917
|
+
positions = AssetPositionIterator(self)
|
|
918
|
+
if not to_date:
|
|
919
|
+
to_date = from_date
|
|
920
|
+
for from_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
921
|
+
logger.info(f"Compute Look-Through for {self} at {from_date}")
|
|
941
922
|
portfolio_total_asset_value = (
|
|
942
|
-
self.primary_portfolio.get_total_asset_under_management(
|
|
923
|
+
self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
|
|
924
|
+
)
|
|
925
|
+
positions.add(
|
|
926
|
+
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
943
927
|
)
|
|
944
|
-
|
|
945
|
-
self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=False)
|
|
928
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
946
929
|
|
|
947
930
|
def update_preferred_classification_per_instrument(self):
|
|
948
931
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -999,47 +982,50 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
999
982
|
def get_representation_label_key(cls):
|
|
1000
983
|
return "{{name}}"
|
|
1001
984
|
|
|
1002
|
-
def bulk_create_positions(
|
|
985
|
+
def bulk_create_positions(
|
|
986
|
+
self, positions: AssetPositionIterator, delete_leftovers: bool = False, force_save: bool = False, **kwargs
|
|
987
|
+
):
|
|
1003
988
|
if positions:
|
|
1004
|
-
update_dates = set()
|
|
1005
|
-
for position in positions:
|
|
1006
|
-
position.portfolio = self
|
|
1007
|
-
update_dates.add(position.date)
|
|
1008
|
-
|
|
1009
989
|
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
1010
990
|
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
1011
991
|
# change completely the trades of a portfolio model and drift it.
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
"
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
992
|
+
|
|
993
|
+
dates = positions.get_dates()
|
|
994
|
+
self.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
995
|
+
|
|
996
|
+
if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
997
|
+
leftover_positions_ids = list(
|
|
998
|
+
self.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
999
|
+
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
1000
|
+
positions_list = list(positions)
|
|
1001
|
+
logger.info(
|
|
1002
|
+
f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
|
|
1003
|
+
)
|
|
1004
|
+
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
1005
|
+
positions_list,
|
|
1006
|
+
update_fields=[
|
|
1007
|
+
"weighting",
|
|
1008
|
+
"initial_price",
|
|
1009
|
+
"initial_currency_fx_rate",
|
|
1010
|
+
"initial_shares",
|
|
1011
|
+
"currency_fx_rate_instrument_to_usd",
|
|
1012
|
+
"currency_fx_rate_portfolio_to_usd",
|
|
1013
|
+
"underlying_quote_price",
|
|
1014
|
+
"portfolio",
|
|
1015
|
+
"portfolio_created",
|
|
1016
|
+
"underlying_instrument",
|
|
1017
|
+
],
|
|
1018
|
+
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
1019
|
+
update_conflicts=True,
|
|
1020
|
+
batch_size=10000,
|
|
1021
|
+
)
|
|
1022
|
+
if delete_leftovers:
|
|
1023
|
+
objs_ids = list(map(lambda x: x.id, objs))
|
|
1024
|
+
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
1025
|
+
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
1026
|
+
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
1027
|
+
for update_date, changed_weights in positions.get_weights().items():
|
|
1028
|
+
self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
|
|
1043
1029
|
|
|
1044
1030
|
@classmethod
|
|
1045
1031
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
@@ -1053,54 +1039,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1053
1039
|
def check_share_diff(self, val_date: date) -> bool:
|
|
1054
1040
|
return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
|
|
1055
1041
|
|
|
1056
|
-
def get_returns(
|
|
1057
|
-
self,
|
|
1058
|
-
from_date: date,
|
|
1059
|
-
to_date: date,
|
|
1060
|
-
ffill_returns: bool = True,
|
|
1061
|
-
convert_to_portfolio_currency: bool = True,
|
|
1062
|
-
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
1063
|
-
"""
|
|
1064
|
-
Utility methods to get instrument returns for a given date range
|
|
1065
|
-
|
|
1066
|
-
Args:
|
|
1067
|
-
from_date: date range lower bound
|
|
1068
|
-
to_date: date range upper bound
|
|
1069
|
-
|
|
1070
|
-
Returns:
|
|
1071
|
-
Return a tuple of the returns and the last prices series for conveniance
|
|
1072
|
-
"""
|
|
1073
|
-
instruments = list(
|
|
1074
|
-
self.assets.filter(date__gte=from_date, date__lte=to_date)
|
|
1075
|
-
.values_list("underlying_quote", flat=True)
|
|
1076
|
-
.distinct("underlying_quote")
|
|
1077
|
-
)
|
|
1078
|
-
prices = InstrumentPrice.objects.filter(
|
|
1079
|
-
instrument__in=instruments, date__gte=from_date, date__lte=to_date
|
|
1080
|
-
).annotate(
|
|
1081
|
-
fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies(
|
|
1082
|
-
"date", "instrument__currency", self.currency
|
|
1083
|
-
),
|
|
1084
|
-
price_fx_portfolio=F("net_value") * F("fx_rate") if convert_to_portfolio_currency else F("net_value"),
|
|
1085
|
-
)
|
|
1086
|
-
prices_df = (
|
|
1087
|
-
pd.DataFrame(
|
|
1088
|
-
prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
1089
|
-
columns=["instrument", "price_fx_portfolio", "date"],
|
|
1090
|
-
)
|
|
1091
|
-
.pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
1092
|
-
.astype(float)
|
|
1093
|
-
.sort_index()
|
|
1094
|
-
)
|
|
1095
|
-
if prices_df.empty:
|
|
1096
|
-
raise InvalidAnalyticPortfolio()
|
|
1097
|
-
ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
|
|
1098
|
-
prices_df = prices_df.reindex(ts)
|
|
1099
|
-
if ffill_returns:
|
|
1100
|
-
prices_df = prices_df.ffill()
|
|
1101
|
-
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
1102
|
-
return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df.replace([np.inf, -np.inf, np.nan], None)
|
|
1103
|
-
|
|
1104
1042
|
@classmethod
|
|
1105
1043
|
def get_contribution_df(cls, data, need_normalize: bool = False):
|
|
1106
1044
|
df = pd.DataFrame(
|
|
@@ -1241,20 +1179,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1241
1179
|
return portfolio
|
|
1242
1180
|
|
|
1243
1181
|
|
|
1244
|
-
def default_estimate_net_value(
|
|
1245
|
-
|
|
1182
|
+
def default_estimate_net_value(
|
|
1183
|
+
val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
|
|
1184
|
+
) -> float | None:
|
|
1185
|
+
portfolio: Portfolio = instrument.portfolio
|
|
1246
1186
|
previous_val_date = (val_date - BDay(1)).date()
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
last_price := instrument.get_latest_price(previous_val_date)
|
|
1252
|
-
)
|
|
1253
|
-
):
|
|
1187
|
+
if not weights:
|
|
1188
|
+
weights = portfolio.get_weights(previous_val_date)
|
|
1189
|
+
# 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
|
|
1190
|
+
if weights and (last_price := instrument.get_latest_price(previous_val_date)):
|
|
1254
1191
|
with suppress(
|
|
1255
1192
|
IndexError, InvalidAnalyticPortfolio
|
|
1256
1193
|
): # we silent any indexerror introduced by no returns for the past days
|
|
1257
|
-
analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_val_date)
|
|
1194
|
+
analytic_portfolio, _ = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
|
|
1258
1195
|
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1259
1196
|
|
|
1260
1197
|
|
|
@@ -1268,9 +1205,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
|
|
|
1268
1205
|
):
|
|
1269
1206
|
with suppress(AssetPosition.DoesNotExist):
|
|
1270
1207
|
earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
|
|
1271
|
-
|
|
1272
|
-
instance.portfolio.id, earliest_primary_position_date, date.today()
|
|
1273
|
-
)
|
|
1208
|
+
compute_lookthrough_as_task.delay(instance.portfolio.id, earliest_primary_position_date, date.today())
|
|
1274
1209
|
|
|
1275
1210
|
|
|
1276
1211
|
@shared_task(queue="portfolio")
|
|
@@ -1280,9 +1215,9 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
|
|
|
1280
1215
|
|
|
1281
1216
|
|
|
1282
1217
|
@shared_task(queue="portfolio")
|
|
1283
|
-
def
|
|
1218
|
+
def compute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
|
|
1284
1219
|
portfolio = Portfolio.objects.get(id=portfolio_id)
|
|
1285
|
-
portfolio.
|
|
1220
|
+
portfolio.compute_lookthrough(start, to_date=end)
|
|
1286
1221
|
|
|
1287
1222
|
|
|
1288
1223
|
@receiver(investable_universe_updated, sender="wbfdm.Instrument")
|
|
@@ -76,11 +76,11 @@ class DailyPortfolioCashFlow(ImportMixin, WBModel):
|
|
|
76
76
|
|
|
77
77
|
with suppress(self.DoesNotExist):
|
|
78
78
|
if self.total_assets is None or self.pending:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
prev = self.portfolio.daily_cashflows.filter(value_date__lt=self.value_date).latest("value_date")
|
|
80
|
+
if prev.pending:
|
|
81
|
+
self.total_assets = prev.estimated_total_assets
|
|
82
|
+
else:
|
|
83
|
+
self.total_assets = prev.total_assets
|
|
84
84
|
|
|
85
85
|
if self.total_assets is None:
|
|
86
86
|
self.total_assets = 0
|
wbportfolio/models/roles.py
CHANGED
|
@@ -57,11 +57,7 @@ class PortfolioRole(models.Model):
|
|
|
57
57
|
) or self.role_type in [
|
|
58
58
|
self.RoleType.PORTFOLIO_MANAGER,
|
|
59
59
|
self.RoleType.ANALYST,
|
|
60
|
-
], self.default_error_messages[
|
|
61
|
-
"manager"
|
|
62
|
-
].format(
|
|
63
|
-
model="instrument"
|
|
64
|
-
)
|
|
60
|
+
], self.default_error_messages["manager"].format(model="instrument")
|
|
65
61
|
|
|
66
62
|
assert (self.start and self.end and self.start < self.end) or (
|
|
67
63
|
not self.start or not self.end
|