wbportfolio 1.49.9__py2.py3-none-any.whl → 1.49.11__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 +154 -0
- wbportfolio/models/portfolio.py +205 -255
- 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.11.dist-info}/METADATA +1 -1
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/RECORD +16 -16
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/WHEEL +0 -0
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.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,73 @@ 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
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from wbportfolio.models.transactions.trade_proposals import TradeProposal
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_prices(instrument_ids: list[int], from_date: date, to_date: date) -> dict[date, dict[int, float]]:
|
|
58
|
+
"""
|
|
59
|
+
Utility to fetch raw prices
|
|
60
|
+
"""
|
|
61
|
+
prices = InstrumentPrice.objects.filter(instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date)
|
|
62
|
+
df = (
|
|
63
|
+
pd.DataFrame(
|
|
64
|
+
prices.filter_only_valid_prices().values_list("instrument", "net_value", "date"),
|
|
65
|
+
columns=["instrument", "net_value", "date"],
|
|
66
|
+
)
|
|
67
|
+
.pivot_table(index="date", values="net_value", columns="instrument")
|
|
68
|
+
.astype(float)
|
|
69
|
+
.sort_index()
|
|
70
|
+
)
|
|
71
|
+
ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
|
|
72
|
+
df = df.reindex(ts)
|
|
73
|
+
df = df.ffill()
|
|
74
|
+
df.index = pd.to_datetime(df.index)
|
|
75
|
+
return {ts.date(): row for ts, row in df.to_dict("index").items()}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_returns(
|
|
79
|
+
instrument_ids: list[int],
|
|
80
|
+
from_date: date,
|
|
81
|
+
to_date: date,
|
|
82
|
+
to_currency: Currency | None = None,
|
|
83
|
+
ffill_returns: bool = True,
|
|
84
|
+
) -> pd.DataFrame:
|
|
85
|
+
"""
|
|
86
|
+
Utility methods to get instrument returns for a given date range
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
from_date: date range lower bound
|
|
90
|
+
to_date: date range upper bound
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Return a tuple of the returns and the last prices series for conveniance
|
|
94
|
+
"""
|
|
95
|
+
if to_currency:
|
|
96
|
+
fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
|
|
97
|
+
else:
|
|
98
|
+
fx_rate = Value(Decimal(1.0))
|
|
99
|
+
prices = InstrumentPrice.objects.filter(
|
|
100
|
+
instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
|
|
101
|
+
).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
|
|
102
|
+
prices_df = (
|
|
103
|
+
pd.DataFrame(
|
|
104
|
+
prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
105
|
+
columns=["instrument", "price_fx_portfolio", "date"],
|
|
106
|
+
)
|
|
107
|
+
.pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
108
|
+
.astype(float)
|
|
109
|
+
.sort_index()
|
|
110
|
+
)
|
|
111
|
+
if prices_df.empty:
|
|
112
|
+
raise InvalidAnalyticPortfolio()
|
|
113
|
+
ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
|
|
114
|
+
prices_df = prices_df.reindex(ts)
|
|
115
|
+
if ffill_returns:
|
|
116
|
+
prices_df = prices_df.ffill()
|
|
117
|
+
prices_df.index = pd.to_datetime(prices_df.index)
|
|
118
|
+
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
119
|
+
return returns.replace([np.inf, -np.inf, np.nan], 0)
|
|
115
120
|
|
|
116
121
|
|
|
117
122
|
class DefaultPortfolioQueryset(QuerySet):
|
|
@@ -185,7 +190,7 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
|
|
|
185
190
|
super()
|
|
186
191
|
.get_queryset()
|
|
187
192
|
.annotate(asset_exists=Exists(AssetPosition.unannotated_objects.filter(portfolio=OuterRef("pk"))))
|
|
188
|
-
.filter(asset_exists=True
|
|
193
|
+
.filter(Q(asset_exists=True) & (Q(is_tracked=True) | Q(is_manageable=True)))
|
|
189
194
|
)
|
|
190
195
|
|
|
191
196
|
|
|
@@ -337,7 +342,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
337
342
|
|
|
338
343
|
@property
|
|
339
344
|
def can_be_rebalanced(self):
|
|
340
|
-
return self.
|
|
345
|
+
return self.is_manageable and not self.is_lookthrough
|
|
341
346
|
|
|
342
347
|
def delete(self, **kwargs):
|
|
343
348
|
super().delete(**kwargs)
|
|
@@ -374,7 +379,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
374
379
|
)
|
|
375
380
|
)
|
|
376
381
|
|
|
377
|
-
def get_analytic_portfolio(
|
|
382
|
+
def get_analytic_portfolio(
|
|
383
|
+
self, val_date: date, weights: dict[int, float] | None = None, **kwargs
|
|
384
|
+
) -> AnalyticPortfolio:
|
|
378
385
|
"""
|
|
379
386
|
Return the analytic portfolio associated with this portfolio at the given date
|
|
380
387
|
|
|
@@ -385,18 +392,18 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
385
392
|
Returns:
|
|
386
393
|
The instantiated analytic portfolio
|
|
387
394
|
"""
|
|
388
|
-
|
|
395
|
+
if not weights:
|
|
396
|
+
weights = self.get_weights(val_date)
|
|
389
397
|
return_date = (val_date + BDay(1)).date()
|
|
390
|
-
returns
|
|
398
|
+
returns = get_returns(
|
|
399
|
+
list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
|
|
400
|
+
)
|
|
391
401
|
if pd.Timestamp(return_date) not in returns.index:
|
|
392
402
|
raise InvalidAnalyticPortfolio()
|
|
393
403
|
returns = returns.fillna(0) # not sure this is what we want
|
|
394
|
-
return (
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
weights=weights,
|
|
398
|
-
),
|
|
399
|
-
prices,
|
|
404
|
+
return AnalyticPortfolio(
|
|
405
|
+
X=returns,
|
|
406
|
+
weights=weights,
|
|
400
407
|
)
|
|
401
408
|
|
|
402
409
|
def is_invested_at_date(self, val_date: date) -> bool:
|
|
@@ -682,6 +689,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
682
689
|
force_recompute_weighting: bool = False,
|
|
683
690
|
compute_metrics: bool = False,
|
|
684
691
|
evaluate_rebalancer: bool = True,
|
|
692
|
+
changed_weights: dict[int, float] | None = None,
|
|
685
693
|
):
|
|
686
694
|
logger.info(f"change at date for {self} at {val_date}")
|
|
687
695
|
|
|
@@ -709,7 +717,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
709
717
|
asset.save()
|
|
710
718
|
|
|
711
719
|
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
712
|
-
self.estimate_net_asset_values(
|
|
720
|
+
self.estimate_net_asset_values(
|
|
721
|
+
(val_date + BDay(1)).date(), weights=changed_weights
|
|
722
|
+
) # updating weighting in t0 influence nav in t+1
|
|
713
723
|
if evaluate_rebalancer:
|
|
714
724
|
self.evaluate_rebalancing(val_date)
|
|
715
725
|
|
|
@@ -731,13 +741,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
731
741
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
732
742
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
|
|
733
743
|
):
|
|
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
|
-
)
|
|
744
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
741
745
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
742
746
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
743
747
|
):
|
|
@@ -757,14 +761,14 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
757
761
|
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
758
762
|
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
759
763
|
|
|
760
|
-
def estimate_net_asset_values(self, val_date: date):
|
|
764
|
+
def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
|
|
761
765
|
for instrument in self.pms_instruments:
|
|
762
766
|
if instrument.is_active_at_date(val_date) and (
|
|
763
767
|
net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
|
|
764
768
|
):
|
|
765
769
|
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
766
770
|
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)
|
|
771
|
+
estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
|
|
768
772
|
if estimated_net_asset_value is not None:
|
|
769
773
|
InstrumentPrice.objects.update_or_create(
|
|
770
774
|
instrument=instrument,
|
|
@@ -780,40 +784,60 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
780
784
|
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
781
785
|
instrument.update_last_valuation_date()
|
|
782
786
|
|
|
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
|
-
"""
|
|
787
|
+
def drift_weights(self, start_date: date, end_date: date) -> tuple[AssetPositionIterator, "TradeProposal"]:
|
|
788
|
+
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
789
789
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
790
|
-
|
|
790
|
+
# Get initial weights
|
|
791
791
|
weights = self.get_weights(start_date) # initial weights
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
792
|
+
if not weights:
|
|
793
|
+
previous_date = self.assets.filter(date__lte=start_date).latest("date").date
|
|
794
|
+
drifted_positions, _ = self.drift_weights(previous_date, start_date)
|
|
795
|
+
weights = drifted_positions.get_weights()[start_date]
|
|
796
|
+
|
|
797
|
+
# Get returns and prices data for the whole date range
|
|
798
|
+
instrument_ids = list(weights.keys())
|
|
799
|
+
returns = get_returns(
|
|
800
|
+
instrument_ids,
|
|
801
|
+
(start_date - BDay(3)).date(),
|
|
802
|
+
end_date,
|
|
803
|
+
to_currency=self.currency,
|
|
804
|
+
ffill_returns=True,
|
|
805
|
+
)
|
|
806
|
+
# Get raw prices to speed up asset position creation
|
|
807
|
+
prices = get_prices(instrument_ids, (start_date - BDay(3)).date(), end_date)
|
|
808
|
+
# Instantiate the position iterator with the initial weights
|
|
809
|
+
positions = AssetPositionIterator(self, prices=prices)
|
|
810
|
+
last_trade_proposal = None
|
|
797
811
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
798
812
|
to_date = to_date_ts.date()
|
|
813
|
+
to_is_active = self.is_active_at_date(to_date)
|
|
799
814
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
800
815
|
if rebalancer and rebalancer.is_valid(to_date):
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
816
|
+
last_trade_proposal = rebalancer.evaluate_rebalancing(to_date)
|
|
817
|
+
# if trade proposal/rebalancing is not approved, we cannot continue the drift
|
|
818
|
+
if last_trade_proposal.status != last_trade_proposal.Status.APPROVED:
|
|
819
|
+
break
|
|
820
|
+
target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
|
|
821
|
+
next_weights = {
|
|
822
|
+
underlying_quote_id: float(pos.weighting)
|
|
823
|
+
for underlying_quote_id, pos in target_portfolio.positions_map.items()
|
|
824
|
+
}
|
|
825
|
+
else:
|
|
826
|
+
try:
|
|
827
|
+
last_returns = returns.loc[[to_date_ts], :]
|
|
828
|
+
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
829
|
+
next_weights = analytic_portfolio.get_next_weights()
|
|
830
|
+
except KeyError: # if no return for that date, we break and continue
|
|
831
|
+
next_weights = weights
|
|
832
|
+
if to_is_active:
|
|
833
|
+
positions.add((to_date, next_weights))
|
|
834
|
+
else:
|
|
835
|
+
positions.add(
|
|
836
|
+
(to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
|
|
837
|
+
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
809
838
|
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
|
|
839
|
+
weights = next_weights
|
|
840
|
+
return positions, last_trade_proposal
|
|
817
841
|
|
|
818
842
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
819
843
|
"""
|
|
@@ -824,33 +848,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
824
848
|
to_date: The date to create the new portfolio at
|
|
825
849
|
|
|
826
850
|
"""
|
|
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
851
|
# we don't propagate on already imported portfolio by default
|
|
840
852
|
is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
|
|
841
853
|
if (
|
|
842
|
-
|
|
854
|
+
not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
|
|
843
855
|
): # 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)
|
|
856
|
+
positions, _ = self.drift_weights(from_date, to_date)
|
|
857
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
854
858
|
|
|
855
859
|
def get_lookthrough_positions(
|
|
856
860
|
self,
|
|
@@ -896,10 +900,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
896
900
|
|
|
897
901
|
setattr(position, "path", path)
|
|
898
902
|
position.initial_shares = None
|
|
899
|
-
if portfolio_total_asset_value:
|
|
900
|
-
position.initial_shares = (position.weighting * portfolio_total_asset_value) /
|
|
901
|
-
position.price * position.currency_fx_rate
|
|
902
|
-
)
|
|
903
|
+
if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
|
|
904
|
+
position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
|
|
903
905
|
if child_portfolio := position.underlying_quote.primary_portfolio:
|
|
904
906
|
if with_intermediary_position:
|
|
905
907
|
yield position
|
|
@@ -922,27 +924,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
922
924
|
assets = self.assets.filter(date=val_date)
|
|
923
925
|
return assets
|
|
924
926
|
|
|
925
|
-
def compute_lookthrough(self,
|
|
926
|
-
if not self.primary_portfolio or not self.is_lookthrough:
|
|
927
|
-
raise ValueError(
|
|
928
|
-
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
929
|
-
)
|
|
930
|
-
logger.info(f"Compute Look-Through for {self} at {sync_date}")
|
|
931
|
-
positions = self.primary_portfolio.get_lookthrough_positions(sync_date, portfolio_total_asset_value)
|
|
932
|
-
self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=True)
|
|
933
|
-
|
|
934
|
-
def batch_recompute_lookthrough(self, from_date: date, to_date: date):
|
|
927
|
+
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
935
928
|
if not self.primary_portfolio or not self.is_lookthrough:
|
|
936
929
|
raise ValueError(
|
|
937
930
|
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
938
931
|
)
|
|
939
|
-
positions =
|
|
940
|
-
|
|
932
|
+
positions = AssetPositionIterator(self)
|
|
933
|
+
if not to_date:
|
|
934
|
+
to_date = from_date
|
|
935
|
+
for from_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
936
|
+
logger.info(f"Compute Look-Through for {self} at {from_date}")
|
|
941
937
|
portfolio_total_asset_value = (
|
|
942
|
-
self.primary_portfolio.get_total_asset_under_management(
|
|
938
|
+
self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
|
|
943
939
|
)
|
|
944
|
-
positions.
|
|
945
|
-
|
|
940
|
+
positions.add(
|
|
941
|
+
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
942
|
+
)
|
|
943
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
946
944
|
|
|
947
945
|
def update_preferred_classification_per_instrument(self):
|
|
948
946
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -999,47 +997,50 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
999
997
|
def get_representation_label_key(cls):
|
|
1000
998
|
return "{{name}}"
|
|
1001
999
|
|
|
1002
|
-
def bulk_create_positions(
|
|
1000
|
+
def bulk_create_positions(
|
|
1001
|
+
self, positions: AssetPositionIterator, delete_leftovers: bool = False, force_save: bool = False, **kwargs
|
|
1002
|
+
):
|
|
1003
1003
|
if positions:
|
|
1004
|
-
update_dates = set()
|
|
1005
|
-
for position in positions:
|
|
1006
|
-
position.portfolio = self
|
|
1007
|
-
update_dates.add(position.date)
|
|
1008
|
-
|
|
1009
1004
|
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
1010
1005
|
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
1011
1006
|
# 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
|
-
|
|
1007
|
+
|
|
1008
|
+
dates = positions.get_dates()
|
|
1009
|
+
self.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
1010
|
+
|
|
1011
|
+
if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
1012
|
+
leftover_positions_ids = list(
|
|
1013
|
+
self.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
1014
|
+
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
1015
|
+
positions_list = list(positions)
|
|
1016
|
+
logger.info(
|
|
1017
|
+
f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
|
|
1018
|
+
)
|
|
1019
|
+
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
1020
|
+
positions_list,
|
|
1021
|
+
update_fields=[
|
|
1022
|
+
"weighting",
|
|
1023
|
+
"initial_price",
|
|
1024
|
+
"initial_currency_fx_rate",
|
|
1025
|
+
"initial_shares",
|
|
1026
|
+
"currency_fx_rate_instrument_to_usd",
|
|
1027
|
+
"currency_fx_rate_portfolio_to_usd",
|
|
1028
|
+
"underlying_quote_price",
|
|
1029
|
+
"portfolio",
|
|
1030
|
+
"portfolio_created",
|
|
1031
|
+
"underlying_instrument",
|
|
1032
|
+
],
|
|
1033
|
+
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
1034
|
+
update_conflicts=True,
|
|
1035
|
+
batch_size=10000,
|
|
1036
|
+
)
|
|
1037
|
+
if delete_leftovers:
|
|
1038
|
+
objs_ids = list(map(lambda x: x.id, objs))
|
|
1039
|
+
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
1040
|
+
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
1041
|
+
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
1042
|
+
for update_date, changed_weights in positions.get_weights().items():
|
|
1043
|
+
self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
|
|
1043
1044
|
|
|
1044
1045
|
@classmethod
|
|
1045
1046
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
@@ -1053,54 +1054,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1053
1054
|
def check_share_diff(self, val_date: date) -> bool:
|
|
1054
1055
|
return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
|
|
1055
1056
|
|
|
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
1057
|
@classmethod
|
|
1105
1058
|
def get_contribution_df(cls, data, need_normalize: bool = False):
|
|
1106
1059
|
df = pd.DataFrame(
|
|
@@ -1241,20 +1194,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1241
1194
|
return portfolio
|
|
1242
1195
|
|
|
1243
1196
|
|
|
1244
|
-
def default_estimate_net_value(
|
|
1245
|
-
|
|
1197
|
+
def default_estimate_net_value(
|
|
1198
|
+
val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
|
|
1199
|
+
) -> float | None:
|
|
1200
|
+
portfolio: Portfolio = instrument.portfolio
|
|
1246
1201
|
previous_val_date = (val_date - BDay(1)).date()
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
last_price := instrument.get_latest_price(previous_val_date)
|
|
1252
|
-
)
|
|
1253
|
-
):
|
|
1202
|
+
if not weights:
|
|
1203
|
+
weights = portfolio.get_weights(previous_val_date)
|
|
1204
|
+
# 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
|
|
1205
|
+
if weights and (last_price := instrument.get_latest_price(previous_val_date)):
|
|
1254
1206
|
with suppress(
|
|
1255
1207
|
IndexError, InvalidAnalyticPortfolio
|
|
1256
1208
|
): # we silent any indexerror introduced by no returns for the past days
|
|
1257
|
-
analytic_portfolio
|
|
1209
|
+
analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
|
|
1258
1210
|
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1259
1211
|
|
|
1260
1212
|
|
|
@@ -1268,9 +1220,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
|
|
|
1268
1220
|
):
|
|
1269
1221
|
with suppress(AssetPosition.DoesNotExist):
|
|
1270
1222
|
earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
|
|
1271
|
-
|
|
1272
|
-
instance.portfolio.id, earliest_primary_position_date, date.today()
|
|
1273
|
-
)
|
|
1223
|
+
compute_lookthrough_as_task.delay(instance.portfolio.id, earliest_primary_position_date, date.today())
|
|
1274
1224
|
|
|
1275
1225
|
|
|
1276
1226
|
@shared_task(queue="portfolio")
|
|
@@ -1280,9 +1230,9 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
|
|
|
1280
1230
|
|
|
1281
1231
|
|
|
1282
1232
|
@shared_task(queue="portfolio")
|
|
1283
|
-
def
|
|
1233
|
+
def compute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
|
|
1284
1234
|
portfolio = Portfolio.objects.get(id=portfolio_id)
|
|
1285
|
-
portfolio.
|
|
1235
|
+
portfolio.compute_lookthrough(start, to_date=end)
|
|
1286
1236
|
|
|
1287
1237
|
|
|
1288
1238
|
@receiver(investable_universe_updated, sender="wbfdm.Instrument")
|