wbportfolio 1.54.19__py2.py3-none-any.whl → 1.54.21__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/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 +258 -0
- wbportfolio/models/orders/order_proposals.py +39 -33
- wbportfolio/models/orders/orders.py +18 -21
- wbportfolio/models/portfolio.py +108 -232
- 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/orders/configs/displays/order_proposals.py +6 -5
- wbportfolio/viewsets/orders/orders.py +1 -1
- {wbportfolio-1.54.19.dist-info → wbportfolio-1.54.21.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.19.dist-info → wbportfolio-1.54.21.dist-info}/RECORD +16 -13
- {wbportfolio-1.54.19.dist-info → wbportfolio-1.54.21.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.19.dist-info → wbportfolio-1.54.21.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,
|
|
@@ -42,82 +39,14 @@ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
|
42
39
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
43
40
|
|
|
44
41
|
from . import ProductGroup
|
|
45
|
-
from .exceptions import InvalidAnalyticPortfolio
|
|
46
42
|
|
|
47
43
|
logger = logging.getLogger("pms")
|
|
48
44
|
if TYPE_CHECKING:
|
|
49
|
-
|
|
45
|
+
pass
|
|
50
46
|
|
|
51
47
|
MARKET_HOLIDAY_MAX_DURATION = 15
|
|
52
48
|
|
|
53
49
|
|
|
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
50
|
class DefaultPortfolioQueryset(QuerySet):
|
|
122
51
|
def filter_invested_at_date(self, val_date: date) -> QuerySet:
|
|
123
52
|
"""
|
|
@@ -217,6 +146,7 @@ class PortfolioPortfolioThroughModel(models.Model):
|
|
|
217
146
|
|
|
218
147
|
class Portfolio(DeleteToDisableMixin, WBModel):
|
|
219
148
|
assets: models.QuerySet[AssetPosition]
|
|
149
|
+
builder: AssetPositionBuilder
|
|
220
150
|
|
|
221
151
|
name = models.CharField(
|
|
222
152
|
max_length=255,
|
|
@@ -305,6 +235,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
305
235
|
objects = DefaultPortfolioManager()
|
|
306
236
|
tracked_objects = ActiveTrackedPortfolioManager()
|
|
307
237
|
|
|
238
|
+
def __init__(self, *args, **kwargs):
|
|
239
|
+
self.builder = AssetPositionBuilder(self)
|
|
240
|
+
super().__init__(*args, **kwargs)
|
|
241
|
+
|
|
308
242
|
@property
|
|
309
243
|
def primary_portfolio(self):
|
|
310
244
|
with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
|
|
@@ -358,7 +292,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
358
292
|
try:
|
|
359
293
|
last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
|
|
360
294
|
last_returns = last_returns.to_dict()
|
|
361
|
-
except
|
|
295
|
+
except ValueError:
|
|
362
296
|
last_returns = {}
|
|
363
297
|
positions = []
|
|
364
298
|
for asset in assets:
|
|
@@ -387,7 +321,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
387
321
|
)
|
|
388
322
|
|
|
389
323
|
def get_analytic_portfolio(
|
|
390
|
-
self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool =
|
|
324
|
+
self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
|
|
391
325
|
) -> AnalyticPortfolio:
|
|
392
326
|
"""
|
|
393
327
|
Return the analytic portfolio associated with this portfolio at the given date
|
|
@@ -402,16 +336,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
402
336
|
if not weights:
|
|
403
337
|
weights = self.get_weights(val_date)
|
|
404
338
|
return_date = (val_date + BDay(1)).date()
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return_date,
|
|
409
|
-
to_currency=self.currency,
|
|
410
|
-
use_dl=use_dl,
|
|
411
|
-
**kwargs,
|
|
412
|
-
)
|
|
339
|
+
returns = self.builder.returns
|
|
340
|
+
if pd.Timestamp(return_date) not in returns.index or not set(weights.keys()).issubset(set(returns.columns)):
|
|
341
|
+
returns = self.load_builder_returns(val_date, return_date)
|
|
413
342
|
if pd.Timestamp(return_date) not in returns.index:
|
|
414
|
-
raise
|
|
343
|
+
raise ValueError()
|
|
344
|
+
returns = returns.loc[:return_date, :]
|
|
415
345
|
returns = returns.fillna(0) # not sure this is what we want
|
|
416
346
|
return AnalyticPortfolio(
|
|
417
347
|
X=returns,
|
|
@@ -712,7 +642,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
712
642
|
recompute_weighting: bool = False,
|
|
713
643
|
force_recompute_weighting: bool = False,
|
|
714
644
|
evaluate_rebalancer: bool = True,
|
|
715
|
-
|
|
645
|
+
changed_portfolio: AnalyticPortfolio | None = None,
|
|
716
646
|
broadcast_changes_at_date: bool = True,
|
|
717
647
|
**kwargs,
|
|
718
648
|
):
|
|
@@ -743,7 +673,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
743
673
|
|
|
744
674
|
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
745
675
|
self.estimate_net_asset_values(
|
|
746
|
-
(val_date + BDay(1)).date(),
|
|
676
|
+
(val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
|
|
747
677
|
) # updating weighting in t0 influence nav in t+1
|
|
748
678
|
if evaluate_rebalancer:
|
|
749
679
|
self.evaluate_rebalancing(val_date)
|
|
@@ -760,7 +690,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
760
690
|
val_date,
|
|
761
691
|
recompute_weighting=recompute_weighting,
|
|
762
692
|
force_recompute_weighting=force_recompute_weighting,
|
|
763
|
-
|
|
693
|
+
changed_portfolio=changed_portfolio,
|
|
764
694
|
**kwargs,
|
|
765
695
|
)
|
|
766
696
|
|
|
@@ -771,7 +701,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
771
701
|
type=PortfolioPortfolioThroughModel.Type.PRIMARY,
|
|
772
702
|
portfolio__is_lookthrough=True,
|
|
773
703
|
):
|
|
774
|
-
rel.portfolio.compute_lookthrough(val_date
|
|
704
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
775
705
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
776
706
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
777
707
|
):
|
|
@@ -791,97 +721,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
791
721
|
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
792
722
|
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
793
723
|
|
|
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
|
-
},
|
|
724
|
+
def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
|
|
725
|
+
effective_portfolio_date = (val_date - BDay(1)).date()
|
|
726
|
+
with suppress(ValueError):
|
|
727
|
+
if not analytic_portfolio:
|
|
728
|
+
analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date)
|
|
729
|
+
for instrument in self.pms_instruments:
|
|
730
|
+
# 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
|
+
last_price = instrument.get_latest_price(effective_portfolio_date)
|
|
732
|
+
if (
|
|
733
|
+
instrument.is_active_at_date(val_date)
|
|
734
|
+
and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
|
|
735
|
+
and last_price
|
|
736
|
+
):
|
|
737
|
+
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
738
|
+
net_asset_value_computation_method = import_from_dotted_path(
|
|
739
|
+
net_asset_value_computation_method_path
|
|
811
740
|
)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
741
|
+
estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
|
|
742
|
+
if estimated_net_asset_value is not None:
|
|
743
|
+
InstrumentPrice.objects.update_or_create(
|
|
744
|
+
instrument=instrument,
|
|
745
|
+
date=val_date,
|
|
746
|
+
calculated=True,
|
|
747
|
+
defaults={
|
|
748
|
+
"gross_value": estimated_net_asset_value,
|
|
749
|
+
"net_value": estimated_net_asset_value,
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
if (
|
|
753
|
+
val_date == instrument.last_price_date
|
|
754
|
+
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
755
|
+
instrument.update_last_valuation_date()
|
|
816
756
|
|
|
817
757
|
def drift_weights(
|
|
818
758
|
self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
|
|
819
|
-
) -> tuple[
|
|
759
|
+
) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
|
|
820
760
|
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
761
|
+
|
|
821
762
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
822
763
|
# Get initial weights
|
|
823
764
|
weights = self.get_weights(start_date) # initial weights
|
|
824
765
|
if not weights:
|
|
825
766
|
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)
|
|
767
|
+
_, weights = next(self.drift_weights(previous_date, start_date))
|
|
768
|
+
|
|
842
769
|
last_order_proposal = None
|
|
843
770
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
844
771
|
to_date = to_date_ts.date()
|
|
845
772
|
to_is_active = self.is_active_at_date(to_date)
|
|
846
773
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
847
|
-
|
|
774
|
+
order_proposal = None
|
|
848
775
|
try:
|
|
849
|
-
last_returns = returns.loc[[to_date_ts], :]
|
|
776
|
+
last_returns = self.builder.returns.loc[[to_date_ts], :]
|
|
850
777
|
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
851
778
|
drifted_weights = analytic_portfolio.get_next_weights()
|
|
852
779
|
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
|
-
]
|
|
780
|
+
break
|
|
781
|
+
try:
|
|
782
|
+
order_proposal = self.order_proposals.get(
|
|
783
|
+
trade_date=to_date, rebalancing_model__isnull=True, status="APPROVED"
|
|
865
784
|
)
|
|
866
|
-
|
|
785
|
+
except ObjectDoesNotExist:
|
|
786
|
+
if rebalancer and rebalancer.is_valid(to_date):
|
|
787
|
+
effective_portfolio = PortfolioDTO(
|
|
788
|
+
positions=[
|
|
789
|
+
PositionDTO(
|
|
790
|
+
date=to_date,
|
|
791
|
+
underlying_instrument=i,
|
|
792
|
+
weighting=Decimal(w),
|
|
793
|
+
daily_return=Decimal(last_returns.iloc[-1][i]),
|
|
794
|
+
)
|
|
795
|
+
for i, w in weights.items()
|
|
796
|
+
]
|
|
797
|
+
)
|
|
798
|
+
order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
|
|
799
|
+
if order_proposal:
|
|
800
|
+
last_order_proposal = order_proposal
|
|
867
801
|
if stop_at_rebalancing:
|
|
868
802
|
break
|
|
869
803
|
next_weights = {
|
|
870
804
|
trade.underlying_instrument.id: float(trade._target_weight)
|
|
871
|
-
for trade in
|
|
805
|
+
for trade in order_proposal.get_orders()
|
|
872
806
|
}
|
|
873
|
-
|
|
807
|
+
yield to_date, next_weights
|
|
874
808
|
else:
|
|
875
809
|
next_weights = drifted_weights
|
|
876
810
|
if to_is_active:
|
|
877
|
-
|
|
811
|
+
yield to_date, next_weights
|
|
878
812
|
else:
|
|
879
|
-
|
|
880
|
-
|
|
813
|
+
yield (
|
|
814
|
+
to_date,
|
|
815
|
+
{underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
|
|
881
816
|
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
882
817
|
break
|
|
883
818
|
weights = next_weights
|
|
884
|
-
return
|
|
819
|
+
return last_order_proposal
|
|
885
820
|
|
|
886
821
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
887
822
|
"""
|
|
@@ -897,10 +832,19 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
897
832
|
if (
|
|
898
833
|
not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
|
|
899
834
|
): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
|
|
900
|
-
|
|
901
|
-
self.
|
|
902
|
-
|
|
903
|
-
)
|
|
835
|
+
self.load_builder_returns(from_date, to_date)
|
|
836
|
+
for pos_date, weights in self.drift_weights(from_date, to_date):
|
|
837
|
+
self.builder.add((pos_date, weights))
|
|
838
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
839
|
+
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
|
840
|
+
self.builder.schedule_metric_computation()
|
|
841
|
+
|
|
842
|
+
def load_builder_returns(self, from_date: date, to_date: date) -> pd.DataFrame:
|
|
843
|
+
instruments_ids = list(self.get_weights(from_date).keys())
|
|
844
|
+
for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date, status="APPROVED"):
|
|
845
|
+
instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
|
|
846
|
+
self.builder.load_returns(set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date())
|
|
847
|
+
return self.builder.returns
|
|
904
848
|
|
|
905
849
|
def get_lookthrough_positions(
|
|
906
850
|
self,
|
|
@@ -970,12 +914,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
970
914
|
assets = list(self.assets.filter(date=val_date))
|
|
971
915
|
return assets
|
|
972
916
|
|
|
973
|
-
def compute_lookthrough(self, from_date: date, to_date: date | None = None
|
|
917
|
+
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
974
918
|
if not self.primary_portfolio or not self.is_lookthrough:
|
|
975
919
|
raise ValueError(
|
|
976
920
|
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
977
921
|
)
|
|
978
|
-
positions = AssetPositionIterator(self)
|
|
979
922
|
if not to_date:
|
|
980
923
|
to_date = from_date
|
|
981
924
|
for from_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
@@ -983,10 +926,12 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
983
926
|
portfolio_total_asset_value = (
|
|
984
927
|
self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
|
|
985
928
|
)
|
|
986
|
-
|
|
929
|
+
self.builder.add(
|
|
987
930
|
list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
|
|
988
931
|
)
|
|
989
|
-
self.bulk_create_positions(
|
|
932
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
933
|
+
self.builder.schedule_change_at_dates(evaluate_rebalancer=False)
|
|
934
|
+
self.builder.schedule_metric_computation()
|
|
990
935
|
|
|
991
936
|
def update_preferred_classification_per_instrument(self):
|
|
992
937
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -1043,64 +988,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1043
988
|
def get_representation_label_key(cls):
|
|
1044
989
|
return "{{name}}"
|
|
1045
990
|
|
|
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
991
|
@classmethod
|
|
1105
992
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
1106
993
|
if isinstance(portfolio_data, int):
|
|
@@ -1253,20 +1140,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1253
1140
|
return portfolio
|
|
1254
1141
|
|
|
1255
1142
|
|
|
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))
|
|
1143
|
+
def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
|
|
1144
|
+
with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
|
|
1145
|
+
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1270
1146
|
|
|
1271
1147
|
|
|
1272
1148
|
@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)
|