wbportfolio 1.54.14__py2.py3-none-any.whl → 1.54.16__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/admin/__init__.py +2 -0
- wbportfolio/admin/orders/__init__.py +2 -0
- wbportfolio/admin/orders/order_proposals.py +14 -0
- wbportfolio/admin/orders/orders.py +30 -0
- wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -1
- wbportfolio/admin/transactions/trades.py +2 -17
- wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
- wbportfolio/factories/__init__.py +2 -1
- wbportfolio/factories/orders/__init__.py +2 -0
- wbportfolio/factories/orders/order_proposals.py +17 -0
- wbportfolio/factories/orders/orders.py +21 -0
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/factories/trades.py +2 -13
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/orders.py +11 -0
- wbportfolio/import_export/handlers/trade.py +20 -20
- wbportfolio/import_export/resources/trades.py +2 -2
- wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
- wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
- wbportfolio/models/__init__.py +2 -0
- wbportfolio/models/orders/__init__.py +2 -0
- wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +304 -264
- wbportfolio/models/orders/orders.py +243 -0
- wbportfolio/models/portfolio.py +16 -19
- wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
- wbportfolio/models/transactions/__init__.py +0 -2
- wbportfolio/models/transactions/trades.py +10 -450
- wbportfolio/pms/analytics/portfolio.py +10 -6
- wbportfolio/pms/analytics/utils.py +9 -0
- wbportfolio/pms/trading/handler.py +28 -27
- wbportfolio/pms/typing.py +18 -7
- wbportfolio/rebalancing/decorators.py +1 -1
- wbportfolio/rebalancing/models/composite.py +3 -7
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
- wbportfolio/serializers/__init__.py +1 -0
- wbportfolio/serializers/orders/__init__.py +2 -0
- wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +30 -17
- wbportfolio/serializers/orders/orders.py +187 -0
- wbportfolio/serializers/portfolios.py +7 -7
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/serializers/transactions/__init__.py +1 -5
- wbportfolio/serializers/transactions/trades.py +1 -182
- wbportfolio/tests/conftest.py +4 -2
- wbportfolio/tests/models/orders/__init__.py +0 -0
- wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +214 -250
- wbportfolio/tests/models/test_portfolios.py +11 -10
- wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
- wbportfolio/tests/models/transactions/test_trades.py +0 -20
- wbportfolio/tests/rebalancing/test_models.py +24 -28
- wbportfolio/tests/signals.py +10 -10
- wbportfolio/tests/tests.py +1 -1
- wbportfolio/urls.py +7 -7
- wbportfolio/viewsets/__init__.py +2 -0
- wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
- wbportfolio/viewsets/configs/buttons/trades.py +0 -8
- wbportfolio/viewsets/configs/display/__init__.py +0 -2
- wbportfolio/viewsets/configs/display/portfolios.py +5 -5
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/display/trades.py +1 -225
- wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
- wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
- wbportfolio/viewsets/orders/__init__.py +6 -0
- wbportfolio/viewsets/orders/configs/__init__.py +4 -0
- wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
- wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +21 -21
- wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
- wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
- wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
- wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
- wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
- wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
- wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
- wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -46
- wbportfolio/viewsets/orders/orders.py +219 -0
- wbportfolio/viewsets/portfolios.py +12 -12
- wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
- wbportfolio/viewsets/transactions/__init__.py +1 -7
- wbportfolio/viewsets/transactions/trades.py +1 -199
- {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.16.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.16.dist-info}/RECORD +85 -58
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
- {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.16.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.14.dist-info → wbportfolio-1.54.16.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.db.models import (
|
|
7
|
+
Sum,
|
|
8
|
+
)
|
|
9
|
+
from ordered_model.models import OrderedModel
|
|
10
|
+
from wbcore.contrib.io.mixins import ImportMixin
|
|
11
|
+
|
|
12
|
+
from wbportfolio.import_export.handlers.trade import TradeImportHandler
|
|
13
|
+
from wbportfolio.models.asset import AssetPosition
|
|
14
|
+
from wbportfolio.models.transactions.transactions import TransactionMixin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
18
|
+
import_export_handler_class = TradeImportHandler
|
|
19
|
+
|
|
20
|
+
ORDER_WEIGHTING_PRECISION = (
|
|
21
|
+
8 # we need to match the asset position weighting. Skfolio advices using a even smaller number (5)
|
|
22
|
+
)
|
|
23
|
+
currency = None
|
|
24
|
+
|
|
25
|
+
class Type(models.TextChoices):
|
|
26
|
+
REBALANCE = "REBALANCE", "Rebalance"
|
|
27
|
+
DECREASE = "DECREASE", "Decrease"
|
|
28
|
+
INCREASE = "INCREASE", "Increase"
|
|
29
|
+
BUY = "BUY", "Buy"
|
|
30
|
+
SELL = "SELL", "Sell"
|
|
31
|
+
NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
|
|
32
|
+
|
|
33
|
+
order_type = models.CharField(max_length=32, default=Type.BUY, choices=Type.choices, verbose_name="Trade Type")
|
|
34
|
+
shares = models.DecimalField(
|
|
35
|
+
max_digits=15,
|
|
36
|
+
decimal_places=4,
|
|
37
|
+
default=Decimal("0.0"),
|
|
38
|
+
help_text="The number of shares that were traded.",
|
|
39
|
+
verbose_name="Shares",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
weighting = models.DecimalField(
|
|
43
|
+
max_digits=9,
|
|
44
|
+
decimal_places=ORDER_WEIGHTING_PRECISION,
|
|
45
|
+
default=Decimal(0),
|
|
46
|
+
help_text="The weight to be multiplied against the target",
|
|
47
|
+
verbose_name="Weight",
|
|
48
|
+
)
|
|
49
|
+
order_proposal = models.ForeignKey(
|
|
50
|
+
to="wbportfolio.OrderProposal",
|
|
51
|
+
related_name="orders",
|
|
52
|
+
on_delete=models.CASCADE,
|
|
53
|
+
help_text="The Order Proposal this trade is coming from",
|
|
54
|
+
)
|
|
55
|
+
daily_return = models.DecimalField(
|
|
56
|
+
max_digits=ORDER_WEIGHTING_PRECISION * 2
|
|
57
|
+
+ 3, # we don't expect any drift factor to be in the order of magnitude greater than 1000
|
|
58
|
+
decimal_places=ORDER_WEIGHTING_PRECISION
|
|
59
|
+
* 2, # we need a higher precision for this factor to avoid float inprecision
|
|
60
|
+
default=Decimal(0.0),
|
|
61
|
+
verbose_name="Daily Return",
|
|
62
|
+
help_text="The Ex-Post daily return",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
|
|
66
|
+
warnings = []
|
|
67
|
+
# 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
|
+
if self.order_proposal and not self.portfolio.only_weighting:
|
|
69
|
+
shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
|
|
70
|
+
if shares != self.shares:
|
|
71
|
+
warnings.append(
|
|
72
|
+
f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
|
|
73
|
+
)
|
|
74
|
+
shares = round(shares) # ensure fractional shares are converted into integer
|
|
75
|
+
# we need to recompute the delta weight has we changed the number of shares
|
|
76
|
+
if shares != self.shares:
|
|
77
|
+
self.shares = shares
|
|
78
|
+
if portfolio_total_asset_value:
|
|
79
|
+
self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
|
|
80
|
+
|
|
81
|
+
if not self.price:
|
|
82
|
+
warnings.append(f"No price for {self.underlying_instrument.computed_str}")
|
|
83
|
+
if abs(self._target_weight) < 1e-8:
|
|
84
|
+
warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
|
|
85
|
+
return warnings
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def product(self):
|
|
89
|
+
from wbportfolio.models.products import Product
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return Product.objects.get(id=self.underlying_instrument.id)
|
|
93
|
+
except Product.DoesNotExist:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
@admin.display(description="Last Effective Date")
|
|
98
|
+
def _last_effective_date(self) -> date:
|
|
99
|
+
if hasattr(self, "last_effective_date"):
|
|
100
|
+
return self.last_effective_date
|
|
101
|
+
elif (
|
|
102
|
+
assets := AssetPosition.unannotated_objects.filter(
|
|
103
|
+
date__lt=self.value_date,
|
|
104
|
+
portfolio=self.portfolio,
|
|
105
|
+
)
|
|
106
|
+
).exists():
|
|
107
|
+
return assets.latest("date").date
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
@admin.display(description="Effective Weight")
|
|
111
|
+
def _previous_weight(self) -> Decimal:
|
|
112
|
+
if hasattr(self, "previous_weight"):
|
|
113
|
+
return self.previous_weight
|
|
114
|
+
return AssetPosition.unannotated_objects.filter(
|
|
115
|
+
underlying_quote=self.underlying_instrument,
|
|
116
|
+
date=self._last_effective_date,
|
|
117
|
+
portfolio=self.portfolio,
|
|
118
|
+
).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
@admin.display(description="Effective Weight")
|
|
122
|
+
def _effective_weight(self) -> Decimal:
|
|
123
|
+
if hasattr(self, "effective_weight"):
|
|
124
|
+
return self.effective_weight
|
|
125
|
+
return self.order_proposal.get_orders().get(id=self.id).effective_weight
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
@admin.display(description="Effective Shares")
|
|
129
|
+
def _effective_shares(self) -> Decimal:
|
|
130
|
+
return getattr(
|
|
131
|
+
self,
|
|
132
|
+
"effective_shares",
|
|
133
|
+
AssetPosition.objects.filter(
|
|
134
|
+
underlying_quote=self.underlying_instrument,
|
|
135
|
+
date=self.value_date,
|
|
136
|
+
portfolio=self.portfolio,
|
|
137
|
+
).aggregate(s=Sum("shares"))["s"]
|
|
138
|
+
or Decimal(0),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
@admin.display(description="Target Weight")
|
|
143
|
+
def _target_weight(self) -> Decimal:
|
|
144
|
+
return getattr(
|
|
145
|
+
self, "target_weight", round(self._effective_weight + self.weighting, self.ORDER_WEIGHTING_PRECISION)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@_target_weight.setter
|
|
149
|
+
def _target_weight(self, target_weight):
|
|
150
|
+
self.weighting = Decimal(target_weight) - self._effective_weight
|
|
151
|
+
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
@admin.display(description="Target Shares")
|
|
155
|
+
def _target_shares(self) -> Decimal:
|
|
156
|
+
return getattr(self, "target_shares", self._effective_shares + self.shares)
|
|
157
|
+
|
|
158
|
+
order_with_respect_to = "order_proposal"
|
|
159
|
+
|
|
160
|
+
class Meta(OrderedModel.Meta):
|
|
161
|
+
verbose_name = "Order"
|
|
162
|
+
verbose_name_plural = "Orders"
|
|
163
|
+
indexes = [
|
|
164
|
+
models.Index(fields=["order_proposal"]),
|
|
165
|
+
models.Index(fields=["underlying_instrument", "value_date"]),
|
|
166
|
+
models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
|
|
167
|
+
models.Index(fields=["order_proposal", "underlying_instrument"]),
|
|
168
|
+
# models.Index(fields=["date", "underlying_instrument"]),
|
|
169
|
+
]
|
|
170
|
+
constraints = [
|
|
171
|
+
models.UniqueConstraint(
|
|
172
|
+
fields=["order_proposal", "underlying_instrument"],
|
|
173
|
+
name="unique_order",
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
# notification_email_template = "portfolio/email/trade_notification.html"
|
|
177
|
+
|
|
178
|
+
def save(self, *args, **kwargs):
|
|
179
|
+
self.portfolio = self.order_proposal.portfolio
|
|
180
|
+
self.value_date = self.order_proposal.trade_date
|
|
181
|
+
|
|
182
|
+
if abs(self.weighting) < 10e-6:
|
|
183
|
+
self.weighting = Decimal("0")
|
|
184
|
+
if not self.underlying_instrument.is_investable_universe:
|
|
185
|
+
self.underlying_instrument.is_investable_universe = True
|
|
186
|
+
self.underlying_instrument.save()
|
|
187
|
+
|
|
188
|
+
if not self.price:
|
|
189
|
+
# we try to get the price if not provided directly from the underlying instrument
|
|
190
|
+
self.price = self.get_price()
|
|
191
|
+
if self.portfolio.only_weighting:
|
|
192
|
+
estimated_shares = self.order_proposal.get_estimated_shares(
|
|
193
|
+
self.weighting, self.underlying_instrument, self.price
|
|
194
|
+
)
|
|
195
|
+
if estimated_shares:
|
|
196
|
+
self.shares = estimated_shares
|
|
197
|
+
if self.id:
|
|
198
|
+
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
199
|
+
super().save(*args, **kwargs)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def get_type(cls, weighting, effective_weight, target_weight) -> Type:
|
|
203
|
+
if weighting == 0:
|
|
204
|
+
return Order.Type.NO_CHANGE
|
|
205
|
+
elif weighting is not None:
|
|
206
|
+
if weighting > 0:
|
|
207
|
+
if abs(effective_weight) > 1e-8:
|
|
208
|
+
return Order.Type.INCREASE
|
|
209
|
+
else:
|
|
210
|
+
return Order.Type.BUY
|
|
211
|
+
elif weighting < 0:
|
|
212
|
+
if abs(target_weight) > 1e-8:
|
|
213
|
+
return Order.Type.DECREASE
|
|
214
|
+
else:
|
|
215
|
+
return Order.Type.SELL
|
|
216
|
+
|
|
217
|
+
def get_asset(self) -> AssetPosition:
|
|
218
|
+
asset = AssetPosition(
|
|
219
|
+
underlying_quote=self.underlying_instrument,
|
|
220
|
+
portfolio_created=None,
|
|
221
|
+
portfolio=self.portfolio,
|
|
222
|
+
date=self.value_date,
|
|
223
|
+
initial_currency_fx_rate=self.currency_fx_rate,
|
|
224
|
+
weighting=self._target_weight,
|
|
225
|
+
initial_price=self.price,
|
|
226
|
+
initial_shares=None,
|
|
227
|
+
asset_valuation_date=self.value_date,
|
|
228
|
+
currency=self.currency,
|
|
229
|
+
is_estimated=False,
|
|
230
|
+
)
|
|
231
|
+
asset.set_weighting(self._target_weight)
|
|
232
|
+
asset.pre_save()
|
|
233
|
+
return asset
|
|
234
|
+
|
|
235
|
+
def get_price(self) -> Decimal:
|
|
236
|
+
try:
|
|
237
|
+
return self.underlying_instrument.get_price(self.value_date)
|
|
238
|
+
except ValueError:
|
|
239
|
+
return Decimal("0")
|
|
240
|
+
|
|
241
|
+
def __str__(self):
|
|
242
|
+
ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
|
|
243
|
+
return f"{ticker}{self.weighting}"
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -46,7 +46,7 @@ from .exceptions import InvalidAnalyticPortfolio
|
|
|
46
46
|
|
|
47
47
|
logger = logging.getLogger("pms")
|
|
48
48
|
if TYPE_CHECKING:
|
|
49
|
-
from wbportfolio.models.
|
|
49
|
+
from wbportfolio.models.orders.order_proposals import OrderProposal
|
|
50
50
|
|
|
51
51
|
MARKET_HOLIDAY_MAX_DURATION = 15
|
|
52
52
|
|
|
@@ -101,16 +101,17 @@ def get_returns(
|
|
|
101
101
|
.astype(float)
|
|
102
102
|
.sort_index()
|
|
103
103
|
)
|
|
104
|
+
|
|
104
105
|
ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
|
|
105
106
|
df = df.reindex(ts)
|
|
106
107
|
df = df.ffill()
|
|
107
108
|
df.index = pd.to_datetime(df.index)
|
|
108
109
|
prices_df = df["close"]
|
|
110
|
+
|
|
109
111
|
if "fx_rate" in df.columns:
|
|
110
112
|
fx_rate_df = df["fx_rate"].fillna(1.0)
|
|
111
113
|
else:
|
|
112
114
|
fx_rate_df = pd.DataFrame(np.ones(prices_df.shape), index=prices_df.index, columns=prices_df.columns)
|
|
113
|
-
|
|
114
115
|
returns = prices_to_returns(fx_rate_df * prices_df, drop_inceptions_nan=False, fill_nan=True)
|
|
115
116
|
return {ts.date(): row for ts, row in prices_df.to_dict("index").items()}, returns.replace(
|
|
116
117
|
[np.inf, -np.inf, np.nan], 0
|
|
@@ -273,7 +274,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
273
274
|
|
|
274
275
|
is_manageable = models.BooleanField(
|
|
275
276
|
default=False,
|
|
276
|
-
help_text="True if the portfolio can be manually modified (e.g.
|
|
277
|
+
help_text="True if the portfolio can be manually modified (e.g. Order Proposal be submitted or total weight recomputed)",
|
|
277
278
|
)
|
|
278
279
|
is_tracked = models.BooleanField(
|
|
279
280
|
default=True,
|
|
@@ -355,17 +356,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
355
356
|
"returns the dto representation of this portfolio at the specified date"
|
|
356
357
|
assets = self.assets.filter(date=val_date, **extra_kwargs)
|
|
357
358
|
try:
|
|
358
|
-
|
|
359
|
+
last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
|
|
360
|
+
last_returns = last_returns.to_dict()
|
|
359
361
|
except InvalidAnalyticPortfolio:
|
|
360
|
-
|
|
362
|
+
last_returns = {}
|
|
361
363
|
positions = []
|
|
362
364
|
for asset in assets:
|
|
363
|
-
|
|
364
|
-
Decimal(drifted_weights.get(asset.underlying_quote.id, asset.weighting)) / asset.weighting
|
|
365
|
-
if asset.weighting
|
|
366
|
-
else Decimal(1.0)
|
|
367
|
-
)
|
|
368
|
-
positions.append(asset._build_dto(drift_factor=drift_factor))
|
|
365
|
+
positions.append(asset._build_dto(daily_return=last_returns.get(asset.underlying_quote.id, Decimal("0"))))
|
|
369
366
|
|
|
370
367
|
return PortfolioDTO(positions)
|
|
371
368
|
|
|
@@ -447,7 +444,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
447
444
|
create_notification_type(
|
|
448
445
|
"wbportfolio.portfolio.replay_done",
|
|
449
446
|
"Portfolio Replay finished",
|
|
450
|
-
"Sends a notification when a the requested
|
|
447
|
+
"Sends a notification when a the requested order proposal replay is done",
|
|
451
448
|
True,
|
|
452
449
|
True,
|
|
453
450
|
True,
|
|
@@ -784,7 +781,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
784
781
|
|
|
785
782
|
def evaluate_rebalancing(self, val_date: date):
|
|
786
783
|
if hasattr(self, "automatic_rebalancer"):
|
|
787
|
-
# if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a
|
|
784
|
+
# if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a order proposal automatically
|
|
788
785
|
next_business_date = (val_date + BDay(1)).date()
|
|
789
786
|
if self.automatic_rebalancer.is_valid(val_date): # we evaluate the rebalancer in t0 and t+1
|
|
790
787
|
logger.info(f"Evaluate Rebalancing for {self} at {val_date}")
|
|
@@ -818,7 +815,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
818
815
|
|
|
819
816
|
def drift_weights(
|
|
820
817
|
self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
|
|
821
|
-
) -> tuple[AssetPositionIterator, "
|
|
818
|
+
) -> tuple[AssetPositionIterator, "OrderProposal"]:
|
|
822
819
|
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
823
820
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
824
821
|
# Get initial weights
|
|
@@ -841,7 +838,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
841
838
|
# Get raw prices to speed up asset position creation
|
|
842
839
|
# Instantiate the position iterator with the initial weights
|
|
843
840
|
positions = AssetPositionIterator(self, prices=prices)
|
|
844
|
-
|
|
841
|
+
last_order_proposal = None
|
|
845
842
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
846
843
|
to_date = to_date_ts.date()
|
|
847
844
|
to_is_active = self.is_active_at_date(to_date)
|
|
@@ -860,17 +857,17 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
860
857
|
date=to_date,
|
|
861
858
|
underlying_instrument=i,
|
|
862
859
|
weighting=Decimal(w),
|
|
863
|
-
|
|
860
|
+
daily_return=Decimal(last_returns.iloc[-1][i]),
|
|
864
861
|
)
|
|
865
862
|
for i, w in weights.items()
|
|
866
863
|
]
|
|
867
864
|
)
|
|
868
|
-
|
|
865
|
+
last_order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
|
|
869
866
|
if stop_at_rebalancing:
|
|
870
867
|
break
|
|
871
868
|
next_weights = {
|
|
872
869
|
trade.underlying_instrument: float(trade._target_weight)
|
|
873
|
-
for trade in
|
|
870
|
+
for trade in last_order_proposal.get_orders()
|
|
874
871
|
}
|
|
875
872
|
positions.add((to_date, next_weights), is_estimated=False)
|
|
876
873
|
else:
|
|
@@ -883,7 +880,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
883
880
|
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
884
881
|
break
|
|
885
882
|
weights = next_weights
|
|
886
|
-
return positions,
|
|
883
|
+
return positions, last_order_proposal
|
|
887
884
|
|
|
888
885
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
889
886
|
"""
|
|
@@ -14,8 +14,8 @@ from wbcore.utils.importlib import import_from_dotted_path
|
|
|
14
14
|
from wbcore.utils.models import ComplexToStringMixin
|
|
15
15
|
from wbcore.utils.rrules import convert_rrulestr_to_dict, humanize_rrule
|
|
16
16
|
|
|
17
|
+
from wbportfolio.models.orders.order_proposals import OrderProposal
|
|
17
18
|
from wbportfolio.models.portfolio import Portfolio
|
|
18
|
-
from wbportfolio.models.transactions.trade_proposals import TradeProposal
|
|
19
19
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
20
20
|
from wbportfolio.rebalancing.base import AbstractRebalancingModel
|
|
21
21
|
|
|
@@ -78,8 +78,8 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
78
78
|
RebalancingModel, on_delete=models.PROTECT, related_name="rebalancers", verbose_name="Rebalancing Model"
|
|
79
79
|
)
|
|
80
80
|
parameters = models.JSONField(default=dict, verbose_name="Parameters", blank=True)
|
|
81
|
-
|
|
82
|
-
default=False, verbose_name="Apply
|
|
81
|
+
approve_order_proposal_automatically = models.BooleanField(
|
|
82
|
+
default=False, verbose_name="Apply Order Proposal Automatically"
|
|
83
83
|
)
|
|
84
84
|
activation_date = models.DateField(verbose_name="Activation Date")
|
|
85
85
|
frequency = models.CharField(
|
|
@@ -99,19 +99,19 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
99
99
|
|
|
100
100
|
def _get_next_valid_date(self, valid_date: date) -> date:
|
|
101
101
|
pivot_date = valid_date
|
|
102
|
-
while
|
|
103
|
-
portfolio=self.portfolio, status=
|
|
102
|
+
while OrderProposal.objects.filter(
|
|
103
|
+
portfolio=self.portfolio, status=OrderProposal.Status.FAILED, trade_date=pivot_date
|
|
104
104
|
).exists():
|
|
105
105
|
pivot_date = (pivot_date + BDay(1)).date()
|
|
106
106
|
return pivot_date
|
|
107
107
|
|
|
108
108
|
def is_valid(self, trade_date: date) -> bool:
|
|
109
|
-
if
|
|
109
|
+
if OrderProposal.objects.filter(
|
|
110
110
|
portfolio=self.portfolio,
|
|
111
|
-
status=
|
|
111
|
+
status=OrderProposal.Status.APPROVED,
|
|
112
112
|
trade_date=trade_date,
|
|
113
113
|
rebalancing_model__isnull=True,
|
|
114
|
-
).exists(): # if a already approved
|
|
114
|
+
).exists(): # if a already approved order proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")
|
|
115
115
|
return False
|
|
116
116
|
for initial_valid_datetime in self.get_rrule(trade_date):
|
|
117
117
|
initial_valid_date = initial_valid_datetime.date()
|
|
@@ -123,7 +123,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
123
123
|
return False
|
|
124
124
|
|
|
125
125
|
def evaluate_rebalancing(self, trade_date: date, effective_portfolio=None):
|
|
126
|
-
|
|
126
|
+
order_proposal, _ = OrderProposal.objects.get_or_create(
|
|
127
127
|
trade_date=trade_date,
|
|
128
128
|
portfolio=self.portfolio,
|
|
129
129
|
defaults={
|
|
@@ -132,29 +132,29 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
132
132
|
},
|
|
133
133
|
)
|
|
134
134
|
|
|
135
|
-
if
|
|
135
|
+
if order_proposal.rebalancing_model == self.rebalancing_model:
|
|
136
136
|
try:
|
|
137
137
|
logger.info(
|
|
138
138
|
f"Getting target portfolio ({self.portfolio}) for rebalancing model {self.rebalancing_model} for trade date {trade_date:%Y-%m-%d}"
|
|
139
139
|
)
|
|
140
140
|
target_portfolio = self.rebalancing_model.get_target_portfolio(
|
|
141
141
|
self.portfolio,
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
order_proposal.trade_date,
|
|
143
|
+
order_proposal.value_date,
|
|
144
144
|
effective_portfolio=effective_portfolio,
|
|
145
145
|
**self.parameters,
|
|
146
146
|
)
|
|
147
|
-
|
|
148
|
-
approve_automatically=self.
|
|
147
|
+
order_proposal.approve_workflow(
|
|
148
|
+
approve_automatically=self.approve_order_proposal_automatically,
|
|
149
149
|
target_portfolio=target_portfolio,
|
|
150
150
|
effective_portfolio=effective_portfolio,
|
|
151
151
|
)
|
|
152
152
|
except ValidationError as e:
|
|
153
153
|
logger.warning(f"Validation error while approving the orders: {e}")
|
|
154
|
-
# If we encountered a validation error, we set the
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return
|
|
154
|
+
# If we encountered a validation error, we set the order proposal as failed
|
|
155
|
+
order_proposal.status = OrderProposal.Status.FAILED
|
|
156
|
+
order_proposal.save()
|
|
157
|
+
return order_proposal
|
|
158
158
|
|
|
159
159
|
@property
|
|
160
160
|
def rrule(self):
|