wbportfolio 1.54.2__py2.py3-none-any.whl → 1.54.4__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/handlers/trade.py +3 -3
- wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
- wbportfolio/models/asset.py +19 -18
- wbportfolio/models/portfolio.py +100 -90
- wbportfolio/models/transactions/rebalancing.py +22 -12
- wbportfolio/models/transactions/trade_proposals.py +193 -77
- wbportfolio/models/transactions/trades.py +36 -19
- wbportfolio/pms/analytics/portfolio.py +5 -6
- wbportfolio/pms/trading/handler.py +16 -19
- wbportfolio/pms/typing.py +26 -11
- wbportfolio/rebalancing/base.py +12 -1
- wbportfolio/rebalancing/models/equally_weighted.py +10 -13
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +18 -9
- wbportfolio/serializers/transactions/trade_proposals.py +2 -2
- wbportfolio/tests/models/test_portfolios.py +5 -3
- wbportfolio/tests/models/transactions/test_trade_proposals.py +13 -12
- wbportfolio/tests/pms/test_analytics.py +4 -3
- wbportfolio/tests/rebalancing/test_models.py +16 -10
- wbportfolio/viewsets/configs/display/trade_proposals.py +9 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- {wbportfolio-1.54.2.dist-info → wbportfolio-1.54.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.2.dist-info → wbportfolio-1.54.4.dist-info}/RECORD +24 -23
- {wbportfolio-1.54.2.dist-info → wbportfolio-1.54.4.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.2.dist-info → wbportfolio-1.54.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -195,6 +195,8 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
195
195
|
modified_objs: list[models.Model],
|
|
196
196
|
unmodified_objs: list[models.Model],
|
|
197
197
|
):
|
|
198
|
+
from wbportfolio.models.transactions.trade_proposals import replay_as_task
|
|
199
|
+
|
|
198
200
|
for instrument in set(
|
|
199
201
|
map(lambda x: x.underlying_instrument, filter(lambda t: t.is_customer_trade, created_objs + modified_objs))
|
|
200
202
|
):
|
|
@@ -203,9 +205,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
203
205
|
|
|
204
206
|
# if the trade import relates to a trade proposal, we reset the TP after the import to ensure it contains the deleted positions (often forgotten by user)
|
|
205
207
|
for changed_trade_proposal in self.trade_proposals:
|
|
206
|
-
changed_trade_proposal.
|
|
207
|
-
target_portfolio=changed_trade_proposal._build_dto().convert_to_portfolio()
|
|
208
|
-
)
|
|
208
|
+
replay_as_task.delay(changed_trade_proposal.id)
|
|
209
209
|
|
|
210
210
|
def _post_processing_updated_object(self, _object):
|
|
211
211
|
if _object.marked_for_deletion:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-07-10 09:00
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('wbportfolio', '0079_alter_trade_drift_factor'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AlterField(
|
|
15
|
+
model_name='assetposition',
|
|
16
|
+
name='weighting',
|
|
17
|
+
field=models.DecimalField(decimal_places=8, default=Decimal('0'), help_text='The Weight of the Asset on the price date of the Asset.', max_digits=9, verbose_name='Weight'),
|
|
18
|
+
),
|
|
19
|
+
]
|
wbportfolio/models/asset.py
CHANGED
|
@@ -112,19 +112,20 @@ class AssetPositionIterator:
|
|
|
112
112
|
return fx_rate
|
|
113
113
|
|
|
114
114
|
def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
with suppress(KeyError):
|
|
116
|
+
if (_p := self._prices[val_date][instrument.id]) is not None:
|
|
117
|
+
return _p
|
|
118
|
+
return None
|
|
119
119
|
|
|
120
|
-
def _dict_to_model(self, val_date: date, instrument_id: int, weighting: float) -> "AssetPosition":
|
|
120
|
+
def _dict_to_model(self, val_date: date, instrument_id: int, weighting: float, **kwargs) -> "AssetPosition":
|
|
121
121
|
underlying_quote = self._get_instrument(instrument_id)
|
|
122
122
|
currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
|
|
123
123
|
currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
|
|
124
124
|
price = self._get_price(val_date, underlying_quote)
|
|
125
|
-
|
|
125
|
+
|
|
126
|
+
parameters = dict(
|
|
126
127
|
underlying_quote=underlying_quote,
|
|
127
|
-
weighting=weighting,
|
|
128
|
+
weighting=round(weighting, 8),
|
|
128
129
|
date=val_date,
|
|
129
130
|
asset_valuation_date=val_date,
|
|
130
131
|
is_estimated=True,
|
|
@@ -137,15 +138,14 @@ class AssetPositionIterator:
|
|
|
137
138
|
underlying_quote_price=None,
|
|
138
139
|
underlying_instrument=None,
|
|
139
140
|
)
|
|
141
|
+
parameters.update(kwargs)
|
|
142
|
+
position = AssetPosition(**parameters)
|
|
140
143
|
position.pre_save(
|
|
141
144
|
infer_underlying_quote_price=self.infer_underlying_quote_price
|
|
142
145
|
) # 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
|
|
143
146
|
return position
|
|
144
147
|
|
|
145
|
-
def add(
|
|
146
|
-
self,
|
|
147
|
-
positions: list["AssetPosition"] | tuple[date, dict[int, float]],
|
|
148
|
-
):
|
|
148
|
+
def add(self, positions: list["AssetPosition"] | tuple[date, dict[int, float]], **kwargs):
|
|
149
149
|
"""
|
|
150
150
|
Add multiple positions efficiently with batch processing
|
|
151
151
|
|
|
@@ -157,7 +157,7 @@ class AssetPositionIterator:
|
|
|
157
157
|
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
158
158
|
for position in positions:
|
|
159
159
|
if not isinstance(position, AssetPosition):
|
|
160
|
-
position = self._dict_to_model(*position)
|
|
160
|
+
position = self._dict_to_model(*position, **kwargs)
|
|
161
161
|
|
|
162
162
|
# Generate unique composite key
|
|
163
163
|
key = (
|
|
@@ -464,8 +464,8 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
464
464
|
# )
|
|
465
465
|
|
|
466
466
|
weighting = models.DecimalField(
|
|
467
|
-
decimal_places=
|
|
468
|
-
max_digits=
|
|
467
|
+
decimal_places=8,
|
|
468
|
+
max_digits=9,
|
|
469
469
|
default=Decimal(0),
|
|
470
470
|
verbose_name="Weight",
|
|
471
471
|
help_text="The Weight of the Asset on the price date of the Asset.",
|
|
@@ -693,15 +693,15 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
693
693
|
def get_portfolio_total_asset_value(self) -> Decimal:
|
|
694
694
|
return self.portfolio.get_total_asset_value(self.date)
|
|
695
695
|
|
|
696
|
-
def _build_dto(self,
|
|
696
|
+
def _build_dto(self, **kwargs) -> PositionDTO:
|
|
697
697
|
"""
|
|
698
698
|
Data Transfer Object
|
|
699
699
|
Returns:
|
|
700
700
|
DTO position object
|
|
701
701
|
"""
|
|
702
|
-
|
|
702
|
+
parameters = dict(
|
|
703
703
|
underlying_instrument=self.underlying_quote.id,
|
|
704
|
-
weighting=self.weighting
|
|
704
|
+
weighting=self.weighting,
|
|
705
705
|
shares=self._shares,
|
|
706
706
|
date=self.date,
|
|
707
707
|
asset_valuation_date=self.asset_valuation_date,
|
|
@@ -726,8 +726,9 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
726
726
|
price=self._price,
|
|
727
727
|
currency_fx_rate=self._currency_fx_rate,
|
|
728
728
|
portfolio_created=self.portfolio_created.id if self.portfolio_created else None,
|
|
729
|
-
**kwargs,
|
|
730
729
|
)
|
|
730
|
+
parameters.update(kwargs)
|
|
731
|
+
return PositionDTO(**parameters)
|
|
731
732
|
|
|
732
733
|
@cached_property
|
|
733
734
|
@admin.display(description="Adjusting Factor (adjustment)")
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -18,7 +18,6 @@ from django.db.models import (
|
|
|
18
18
|
Q,
|
|
19
19
|
QuerySet,
|
|
20
20
|
Sum,
|
|
21
|
-
Value,
|
|
22
21
|
)
|
|
23
22
|
from django.db.models.signals import post_save
|
|
24
23
|
from django.dispatch import receiver
|
|
@@ -26,12 +25,13 @@ from django.utils import timezone
|
|
|
26
25
|
from django.utils.functional import cached_property
|
|
27
26
|
from pandas._libs.tslibs.offsets import BDay
|
|
28
27
|
from skfolio.preprocessing import prices_to_returns
|
|
29
|
-
from wbcore.contrib.currency.models import Currency
|
|
28
|
+
from wbcore.contrib.currency.models import Currency
|
|
30
29
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
31
30
|
from wbcore.models import WBModel
|
|
32
31
|
from wbcore.utils.importlib import import_from_dotted_path
|
|
33
32
|
from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
|
|
34
33
|
from wbfdm.contrib.metric.tasks import compute_metrics_as_task
|
|
34
|
+
from wbfdm.enums import MarketData
|
|
35
35
|
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
|
|
@@ -45,6 +45,7 @@ from wbportfolio.models.portfolio_relationship import (
|
|
|
45
45
|
from wbportfolio.models.products import Product
|
|
46
46
|
from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
47
47
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
48
|
+
from wbportfolio.pms.typing import Position as PositionDTO
|
|
48
49
|
|
|
49
50
|
from . import ProductGroup
|
|
50
51
|
from .exceptions import InvalidAnalyticPortfolio
|
|
@@ -54,34 +55,13 @@ if TYPE_CHECKING:
|
|
|
54
55
|
from wbportfolio.models.transactions.trade_proposals import TradeProposal
|
|
55
56
|
|
|
56
57
|
|
|
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
58
|
def get_returns(
|
|
79
59
|
instrument_ids: list[int],
|
|
80
60
|
from_date: date,
|
|
81
61
|
to_date: date,
|
|
82
62
|
to_currency: Currency | None = None,
|
|
83
63
|
ffill_returns: bool = True,
|
|
84
|
-
) -> pd.DataFrame:
|
|
64
|
+
) -> tuple[dict[date, dict[int, float]], pd.DataFrame]:
|
|
85
65
|
"""
|
|
86
66
|
Utility methods to get instrument returns for a given date range
|
|
87
67
|
|
|
@@ -92,31 +72,40 @@ def get_returns(
|
|
|
92
72
|
Returns:
|
|
93
73
|
Return a tuple of the returns and the last prices series for conveniance
|
|
94
74
|
"""
|
|
75
|
+
# if to_currency:
|
|
76
|
+
# fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
|
|
77
|
+
# else:
|
|
78
|
+
# fx_rate = Value(Decimal(1.0))
|
|
79
|
+
# prices = InstrumentPrice.objects.filter(
|
|
80
|
+
# instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
|
|
81
|
+
# ).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
|
|
82
|
+
# prices_df = (
|
|
83
|
+
# pd.DataFrame(
|
|
84
|
+
# prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
85
|
+
# columns=["instrument", "price_fx_portfolio", "date"],
|
|
86
|
+
# )
|
|
87
|
+
# .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
88
|
+
# .astype(float)
|
|
89
|
+
# .sort_index()
|
|
90
|
+
# )
|
|
91
|
+
kwargs = dict(from_date=from_date, to_date=to_date, values=[MarketData.CLOSE])
|
|
95
92
|
if to_currency:
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
)
|
|
93
|
+
kwargs["target_currency"] = to_currency.key
|
|
94
|
+
prices_df = pd.DataFrame(Instrument.objects.filter(id__in=instrument_ids).dl.market_data(**kwargs))
|
|
111
95
|
if prices_df.empty:
|
|
112
96
|
raise InvalidAnalyticPortfolio()
|
|
97
|
+
prices_df = prices_df[["instrument_id", "close", "valuation_date"]].pivot(
|
|
98
|
+
index="valuation_date", columns="instrument_id", values="close"
|
|
99
|
+
)
|
|
113
100
|
ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
|
|
114
101
|
prices_df = prices_df.reindex(ts)
|
|
115
102
|
if ffill_returns:
|
|
116
103
|
prices_df = prices_df.ffill()
|
|
117
104
|
prices_df.index = pd.to_datetime(prices_df.index)
|
|
118
105
|
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
119
|
-
return
|
|
106
|
+
return {ts.date(): row for ts, row in prices_df.replace(np.nan, None).to_dict("index").items()}, returns.replace(
|
|
107
|
+
[np.inf, -np.inf, np.nan], 0
|
|
108
|
+
)
|
|
120
109
|
|
|
121
110
|
|
|
122
111
|
class DefaultPortfolioQueryset(QuerySet):
|
|
@@ -353,26 +342,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
353
342
|
instrument.delisted_date = date.today() - timedelta(days=1)
|
|
354
343
|
instrument.save()
|
|
355
344
|
|
|
356
|
-
def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
|
|
345
|
+
def _build_dto(self, val_date: date, include_drift_factor: bool = False, **extra_kwargs) -> PortfolioDTO:
|
|
357
346
|
"returns the dto representation of this portfolio at the specified date"
|
|
358
347
|
assets = self.assets.filter(date=val_date, **extra_kwargs)
|
|
359
348
|
try:
|
|
360
349
|
drifted_weights = self.get_analytic_portfolio(val_date).get_next_weights()
|
|
361
350
|
except InvalidAnalyticPortfolio:
|
|
362
351
|
drifted_weights = {}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
]
|
|
374
|
-
),
|
|
375
|
-
)
|
|
352
|
+
positions = []
|
|
353
|
+
for asset in assets:
|
|
354
|
+
drift_factor = (
|
|
355
|
+
Decimal(drifted_weights.get(asset.underlying_quote.id, asset.weighting)) / asset.weighting
|
|
356
|
+
if asset.weighting
|
|
357
|
+
else Decimal(1.0)
|
|
358
|
+
)
|
|
359
|
+
positions.append(asset._build_dto(drift_factor=drift_factor))
|
|
360
|
+
|
|
361
|
+
return PortfolioDTO(positions)
|
|
376
362
|
|
|
377
363
|
def get_weights(self, val_date: date) -> dict[int, float]:
|
|
378
364
|
"""
|
|
@@ -410,7 +396,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
410
396
|
if not weights:
|
|
411
397
|
weights = self.get_weights(val_date)
|
|
412
398
|
return_date = (val_date + BDay(1)).date()
|
|
413
|
-
returns = get_returns(
|
|
399
|
+
_, returns = get_returns(
|
|
414
400
|
list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
|
|
415
401
|
)
|
|
416
402
|
if pd.Timestamp(return_date) not in returns.index:
|
|
@@ -705,6 +691,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
705
691
|
if portfolio := asset.underlying_instrument.portfolio:
|
|
706
692
|
yield portfolio, asset.weighting
|
|
707
693
|
|
|
694
|
+
def get_next_rebalancing_date(self, start_date: date) -> date | None:
|
|
695
|
+
if automatic_rebalancer := getattr(self, "automatic_rebalancer", None):
|
|
696
|
+
return automatic_rebalancer.get_next_rebalancing_date(start_date)
|
|
697
|
+
|
|
708
698
|
def change_at_date(
|
|
709
699
|
self,
|
|
710
700
|
val_date: date,
|
|
@@ -756,17 +746,20 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
756
746
|
self.handle_controlling_portfolio_change_at_date(val_date)
|
|
757
747
|
|
|
758
748
|
def handle_controlling_portfolio_change_at_date(self, val_date: date):
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
rel.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
749
|
+
if self.is_tracked:
|
|
750
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
751
|
+
dependency_portfolio=self,
|
|
752
|
+
type=PortfolioPortfolioThroughModel.Type.PRIMARY,
|
|
753
|
+
portfolio__is_lookthrough=True,
|
|
754
|
+
):
|
|
755
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
756
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
757
|
+
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
758
|
+
):
|
|
759
|
+
rel.portfolio.evaluate_rebalancing(val_date)
|
|
760
|
+
for dependent_portfolio in self.get_child_portfolios(val_date):
|
|
761
|
+
dependent_portfolio.change_at_date(val_date)
|
|
762
|
+
dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
|
|
770
763
|
|
|
771
764
|
def evaluate_rebalancing(self, val_date: date):
|
|
772
765
|
if hasattr(self, "automatic_rebalancer"):
|
|
@@ -802,7 +795,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
802
795
|
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
803
796
|
instrument.update_last_valuation_date()
|
|
804
797
|
|
|
805
|
-
def drift_weights(
|
|
798
|
+
def drift_weights(
|
|
799
|
+
self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
|
|
800
|
+
) -> tuple[AssetPositionIterator, "TradeProposal"]:
|
|
806
801
|
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
807
802
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
808
803
|
# Get initial weights
|
|
@@ -814,7 +809,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
814
809
|
|
|
815
810
|
# Get returns and prices data for the whole date range
|
|
816
811
|
instrument_ids = list(weights.keys())
|
|
817
|
-
|
|
812
|
+
|
|
813
|
+
prices, returns = get_returns(
|
|
818
814
|
instrument_ids,
|
|
819
815
|
(start_date - BDay(3)).date(),
|
|
820
816
|
end_date,
|
|
@@ -822,7 +818,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
822
818
|
ffill_returns=True,
|
|
823
819
|
)
|
|
824
820
|
# Get raw prices to speed up asset position creation
|
|
825
|
-
prices = get_prices(instrument_ids, (start_date - BDay(3)).date(), end_date)
|
|
826
821
|
# Instantiate the position iterator with the initial weights
|
|
827
822
|
positions = AssetPositionIterator(self, prices=prices)
|
|
828
823
|
last_trade_proposal = None
|
|
@@ -830,30 +825,43 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
830
825
|
to_date = to_date_ts.date()
|
|
831
826
|
to_is_active = self.is_active_at_date(to_date)
|
|
832
827
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
last_returns = returns.loc[[to_date_ts], :]
|
|
831
|
+
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
832
|
+
drifted_weights = analytic_portfolio.get_next_weights()
|
|
833
|
+
except KeyError: # if no return for that date, we break and continue
|
|
834
|
+
drifted_weights = weights
|
|
835
|
+
|
|
833
836
|
if rebalancer and rebalancer.is_valid(to_date):
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
+
effective_portfolio = PortfolioDTO(
|
|
838
|
+
positions=[
|
|
839
|
+
PositionDTO(
|
|
840
|
+
date=to_date,
|
|
841
|
+
underlying_instrument=i,
|
|
842
|
+
weighting=Decimal(w),
|
|
843
|
+
drift_factor=Decimal(drifted_weights.get(i, w) / float(w)) if w else Decimal(1.0),
|
|
844
|
+
)
|
|
845
|
+
for i, w in weights.items()
|
|
846
|
+
]
|
|
847
|
+
)
|
|
848
|
+
last_trade_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
|
|
849
|
+
if stop_at_rebalancing:
|
|
837
850
|
break
|
|
838
|
-
target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
|
|
839
851
|
next_weights = {
|
|
840
|
-
|
|
841
|
-
for
|
|
852
|
+
trade.underlying_instrument: float(trade._target_weight)
|
|
853
|
+
for trade in last_trade_proposal.trades.all().annotate_base_info()
|
|
842
854
|
}
|
|
855
|
+
positions.add((to_date, next_weights), is_estimated=False)
|
|
843
856
|
else:
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
else:
|
|
853
|
-
positions.add(
|
|
854
|
-
(to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
|
|
855
|
-
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
856
|
-
break
|
|
857
|
+
next_weights = drifted_weights
|
|
858
|
+
if to_is_active:
|
|
859
|
+
positions.add((to_date, next_weights))
|
|
860
|
+
else:
|
|
861
|
+
positions.add(
|
|
862
|
+
(to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
|
|
863
|
+
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
864
|
+
break
|
|
857
865
|
weights = next_weights
|
|
858
866
|
return positions, last_trade_proposal
|
|
859
867
|
|
|
@@ -1023,6 +1031,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1023
1031
|
delete_leftovers: bool = False,
|
|
1024
1032
|
force_save: bool = False,
|
|
1025
1033
|
compute_metrics: bool = True,
|
|
1034
|
+
broadcast_changes_at_date: bool = True,
|
|
1026
1035
|
**kwargs,
|
|
1027
1036
|
):
|
|
1028
1037
|
if positions:
|
|
@@ -1071,8 +1080,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1071
1080
|
basket_id=self.id,
|
|
1072
1081
|
basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id,
|
|
1073
1082
|
)
|
|
1074
|
-
|
|
1075
|
-
|
|
1083
|
+
if broadcast_changes_at_date:
|
|
1084
|
+
for update_date, changed_weights in positions.get_weights().items():
|
|
1085
|
+
self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
|
|
1076
1086
|
|
|
1077
1087
|
@classmethod
|
|
1078
1088
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
@@ -43,9 +43,16 @@ class RebalancingModel(models.Model):
|
|
|
43
43
|
return import_from_dotted_path(self.class_path)
|
|
44
44
|
|
|
45
45
|
def get_target_portfolio(
|
|
46
|
-
self,
|
|
46
|
+
self,
|
|
47
|
+
portfolio: Portfolio,
|
|
48
|
+
trade_date: date,
|
|
49
|
+
last_effective_date: date,
|
|
50
|
+
effective_portfolio: PortfolioDTO | None = None,
|
|
51
|
+
**kwargs,
|
|
47
52
|
) -> PortfolioDTO:
|
|
48
|
-
model = self.model_class(
|
|
53
|
+
model = self.model_class(
|
|
54
|
+
portfolio, trade_date, last_effective_date, effective_portfolio=effective_portfolio, **kwargs
|
|
55
|
+
)
|
|
49
56
|
if not model.is_valid():
|
|
50
57
|
raise ValidationError(model.validation_errors)
|
|
51
58
|
return model.get_target_portfolio()
|
|
@@ -115,7 +122,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
115
122
|
break
|
|
116
123
|
return False
|
|
117
124
|
|
|
118
|
-
def evaluate_rebalancing(self, trade_date: date):
|
|
125
|
+
def evaluate_rebalancing(self, trade_date: date, effective_portfolio=None):
|
|
119
126
|
trade_proposal, _ = TradeProposal.objects.get_or_create(
|
|
120
127
|
trade_date=trade_date,
|
|
121
128
|
portfolio=self.portfolio,
|
|
@@ -126,24 +133,27 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
126
133
|
)
|
|
127
134
|
|
|
128
135
|
if trade_proposal.rebalancing_model == self.rebalancing_model:
|
|
129
|
-
trade_proposal.status = TradeProposal.Status.DRAFT
|
|
130
136
|
try:
|
|
131
137
|
logger.info(
|
|
132
138
|
f"Getting target portfolio ({self.portfolio}) for rebalancing model {self.rebalancing_model} for trade date {trade_date:%Y-%m-%d}"
|
|
133
139
|
)
|
|
134
140
|
target_portfolio = self.rebalancing_model.get_target_portfolio(
|
|
135
|
-
self.portfolio,
|
|
141
|
+
self.portfolio,
|
|
142
|
+
trade_proposal.trade_date,
|
|
143
|
+
trade_proposal.value_date,
|
|
144
|
+
effective_portfolio=effective_portfolio,
|
|
145
|
+
**self.parameters,
|
|
146
|
+
)
|
|
147
|
+
trade_proposal.approve_workflow(
|
|
148
|
+
approve_automatically=self.approve_trade_proposal_automatically,
|
|
149
|
+
target_portfolio=target_portfolio,
|
|
150
|
+
effective_portfolio=effective_portfolio,
|
|
136
151
|
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if self.approve_trade_proposal_automatically and self.portfolio.can_be_rebalanced:
|
|
140
|
-
trade_proposal.approve(replay=False)
|
|
141
|
-
except ValidationError:
|
|
152
|
+
except ValidationError as e:
|
|
153
|
+
logger.warning(f"Validation error while approving the orders: {e}")
|
|
142
154
|
# If we encountered a validation error, we set the trade proposal as failed
|
|
143
155
|
trade_proposal.status = TradeProposal.Status.FAILED
|
|
144
|
-
|
|
145
156
|
trade_proposal.save()
|
|
146
|
-
|
|
147
157
|
return trade_proposal
|
|
148
158
|
|
|
149
159
|
@property
|