wbportfolio 1.47.1__py2.py3-none-any.whl → 1.49.0__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/contrib/company_portfolio/tasks.py +8 -4
- wbportfolio/dynamic_preferences_registry.py +9 -0
- wbportfolio/factories/__init__.py +1 -3
- wbportfolio/factories/portfolios.py +0 -12
- wbportfolio/factories/product_groups.py +8 -1
- wbportfolio/factories/products.py +18 -0
- wbportfolio/factories/trades.py +5 -1
- wbportfolio/import_export/handlers/trade.py +8 -0
- wbportfolio/import_export/resources/trades.py +19 -30
- wbportfolio/metric/backends/base.py +3 -15
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/models/__init__.py +1 -2
- wbportfolio/models/asset.py +1 -1
- wbportfolio/models/portfolio.py +20 -13
- wbportfolio/models/products.py +50 -1
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +172 -67
- wbportfolio/models/transactions/trades.py +34 -25
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/preferences.py +6 -1
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/accounts.py +14 -6
- wbportfolio/risk_management/backends/exposure_portfolio.py +36 -5
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trade_proposals.py +2 -13
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tasks.py +4 -1
- wbportfolio/tests/conftest.py +1 -1
- wbportfolio/tests/models/test_portfolios.py +1 -1
- wbportfolio/tests/models/test_products.py +26 -0
- wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
- wbportfolio/tests/models/transactions/test_trades.py +14 -0
- wbportfolio/tests/signals.py +1 -1
- wbportfolio/tests/viewsets/test_performances.py +2 -1
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/trades.py +23 -8
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/portfolios.py +22 -7
- wbportfolio/viewsets/transactions/trade_proposals.py +21 -2
- wbportfolio/viewsets/transactions/trades.py +86 -12
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +46 -44
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -11,11 +11,13 @@ from django.utils.functional import cached_property
|
|
|
11
11
|
from django_fsm import FSMField, transition
|
|
12
12
|
from pandas._libs.tslibs.offsets import BDay
|
|
13
13
|
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
14
|
+
from wbcore.contrib.currency.models import Currency
|
|
14
15
|
from wbcore.contrib.icons import WBIcon
|
|
15
16
|
from wbcore.enums import RequestType
|
|
16
17
|
from wbcore.metadata.configs.buttons import ActionButton
|
|
17
18
|
from wbcore.models import WBModel
|
|
18
|
-
from
|
|
19
|
+
from wbcore.utils.models import CloneMixin
|
|
20
|
+
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
19
21
|
|
|
20
22
|
from wbportfolio.models.roles import PortfolioRole
|
|
21
23
|
from wbportfolio.pms.trading import TradingService
|
|
@@ -30,7 +32,7 @@ logger = logging.getLogger("pms")
|
|
|
30
32
|
SelfTradeProposal = TypeVar("SelfTradeProposal", bound="TradeProposal")
|
|
31
33
|
|
|
32
34
|
|
|
33
|
-
class TradeProposal(RiskCheckMixin, WBModel):
|
|
35
|
+
class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
34
36
|
trade_date = models.DateField(verbose_name="Trading Date")
|
|
35
37
|
|
|
36
38
|
class Status(models.TextChoices):
|
|
@@ -76,17 +78,21 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
76
78
|
def save(self, *args, **kwargs):
|
|
77
79
|
if not self.trade_date and self.portfolio.assets.exists():
|
|
78
80
|
self.trade_date = (self.portfolio.assets.latest("date").date + BDay(1)).date()
|
|
79
|
-
if not self.rebalancing_model and (rebalancer := getattr(self.portfolio, "automatic_rebalancer", None)):
|
|
80
|
-
self.rebalancing_model = rebalancer.rebalancing_model
|
|
81
81
|
super().save(*args, **kwargs)
|
|
82
82
|
if self.status == TradeProposal.Status.APPROVED:
|
|
83
83
|
self.portfolio.change_at_date(self.trade_date)
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
@property
|
|
86
|
+
def checked_object(self):
|
|
87
|
+
return self.portfolio
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def check_evaluation_date(self):
|
|
91
|
+
return self.trade_date
|
|
92
|
+
|
|
93
|
+
@cached_property
|
|
94
|
+
def portfolio_total_asset_value(self) -> Decimal:
|
|
95
|
+
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
90
96
|
|
|
91
97
|
@cached_property
|
|
92
98
|
def validated_trading_service(self) -> TradingService:
|
|
@@ -95,8 +101,8 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
95
101
|
"""
|
|
96
102
|
return TradingService(
|
|
97
103
|
self.trade_date,
|
|
98
|
-
effective_portfolio=self.portfolio._build_dto(self.
|
|
99
|
-
|
|
104
|
+
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
105
|
+
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
100
106
|
)
|
|
101
107
|
|
|
102
108
|
@cached_property
|
|
@@ -149,12 +155,10 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
149
155
|
Returns:
|
|
150
156
|
DTO trade object
|
|
151
157
|
"""
|
|
152
|
-
return (
|
|
153
|
-
TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()])) if self.trades.exists() else None
|
|
154
|
-
)
|
|
158
|
+
return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
|
|
155
159
|
|
|
156
160
|
# Start tools methods
|
|
157
|
-
def
|
|
161
|
+
def _clone(self, **kwargs) -> SelfTradeProposal:
|
|
158
162
|
"""
|
|
159
163
|
Method to clone self as a new trade proposal. It will automatically shift the trade date if a proposal already exists
|
|
160
164
|
Args:
|
|
@@ -176,6 +180,11 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
176
180
|
portfolio=self.portfolio,
|
|
177
181
|
creator=self.creator,
|
|
178
182
|
)
|
|
183
|
+
for trade in self.trades.all():
|
|
184
|
+
trade.id = None
|
|
185
|
+
trade.trade_proposal = trade_proposal_clone
|
|
186
|
+
trade.save()
|
|
187
|
+
|
|
179
188
|
return trade_proposal_clone
|
|
180
189
|
|
|
181
190
|
def normalize_trades(self):
|
|
@@ -191,7 +200,6 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
191
200
|
with suppress(Trade.DoesNotExist):
|
|
192
201
|
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
193
202
|
trade.weighting = round(trade_dto.delta_weight, 6)
|
|
194
|
-
trade.shares = self.estimate_shares(trade)
|
|
195
203
|
trade.save()
|
|
196
204
|
total_target_weight += trade._target_weight
|
|
197
205
|
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
@@ -202,7 +210,7 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
202
210
|
biggest_trade.weighting -= quantize_error
|
|
203
211
|
biggest_trade.save()
|
|
204
212
|
|
|
205
|
-
def
|
|
213
|
+
def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
|
|
206
214
|
if self.rebalancing_model:
|
|
207
215
|
params = {}
|
|
208
216
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -211,50 +219,57 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
211
219
|
return self.rebalancing_model.get_target_portfolio(
|
|
212
220
|
self.portfolio, self.trade_date, self.last_effective_date, **params
|
|
213
221
|
)
|
|
222
|
+
if self.trades.exists():
|
|
223
|
+
return self._build_dto().convert_to_portfolio()
|
|
214
224
|
# Return the current portfolio by default
|
|
215
225
|
return self.portfolio._build_dto(self.last_effective_date)
|
|
216
226
|
|
|
217
|
-
def reset_trades(self, target_portfolio: PortfolioDTO | None = None):
|
|
227
|
+
def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
|
|
218
228
|
"""
|
|
219
229
|
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
220
230
|
"""
|
|
221
231
|
if self.status != TradeProposal.Status.DRAFT:
|
|
222
232
|
raise ValueError("Cannot reset non-draft trade proposal. Revert this trade proposal first.")
|
|
223
233
|
# delete all existing trades
|
|
224
|
-
self.trades.all().delete()
|
|
225
234
|
last_effective_date = self.last_effective_date
|
|
226
235
|
# Get effective and target portfolio
|
|
227
236
|
effective_portfolio = self.portfolio._build_dto(last_effective_date)
|
|
228
237
|
if not target_portfolio:
|
|
229
|
-
target_portfolio = self.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
)
|
|
237
|
-
service.normalize()
|
|
238
|
-
service.is_valid()
|
|
239
|
-
for trade_dto in service.validated_trades:
|
|
240
|
-
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
241
|
-
currency_fx_rate = instrument.currency.convert(
|
|
242
|
-
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
243
|
-
)
|
|
244
|
-
trade = Trade(
|
|
245
|
-
underlying_instrument=instrument,
|
|
246
|
-
transaction_subtype=Trade.Type.BUY if trade_dto.delta_weight > 0 else Trade.Type.SELL,
|
|
247
|
-
currency=instrument.currency,
|
|
248
|
-
value_date=last_effective_date,
|
|
249
|
-
transaction_date=self.trade_date,
|
|
250
|
-
trade_proposal=self,
|
|
251
|
-
portfolio=self.portfolio,
|
|
252
|
-
weighting=trade_dto.delta_weight,
|
|
253
|
-
status=Trade.Status.DRAFT,
|
|
254
|
-
currency_fx_rate=currency_fx_rate,
|
|
238
|
+
target_portfolio = self._get_default_target_portfolio()
|
|
239
|
+
|
|
240
|
+
if target_portfolio:
|
|
241
|
+
service = TradingService(
|
|
242
|
+
self.trade_date,
|
|
243
|
+
effective_portfolio=effective_portfolio,
|
|
244
|
+
target_portfolio=target_portfolio,
|
|
255
245
|
)
|
|
256
|
-
|
|
257
|
-
|
|
246
|
+
if validate_trade:
|
|
247
|
+
service.normalize()
|
|
248
|
+
service.is_valid()
|
|
249
|
+
trades = service.validated_trades
|
|
250
|
+
else:
|
|
251
|
+
trades = service.trades_batch.trades_map.values()
|
|
252
|
+
for trade_dto in trades:
|
|
253
|
+
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
254
|
+
currency_fx_rate = instrument.currency.convert(
|
|
255
|
+
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
256
|
+
)
|
|
257
|
+
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
258
|
+
try:
|
|
259
|
+
trade = self.trades.get(underlying_instrument=instrument)
|
|
260
|
+
except Trade.DoesNotExist:
|
|
261
|
+
trade = Trade(
|
|
262
|
+
underlying_instrument=instrument,
|
|
263
|
+
currency=instrument.currency,
|
|
264
|
+
value_date=last_effective_date,
|
|
265
|
+
transaction_date=self.trade_date,
|
|
266
|
+
trade_proposal=self,
|
|
267
|
+
portfolio=self.portfolio,
|
|
268
|
+
weighting=trade_dto.delta_weight,
|
|
269
|
+
status=Trade.Status.DRAFT,
|
|
270
|
+
currency_fx_rate=currency_fx_rate,
|
|
271
|
+
)
|
|
272
|
+
trade.save()
|
|
258
273
|
|
|
259
274
|
def replay(self):
|
|
260
275
|
last_trade_proposal = self
|
|
@@ -263,17 +278,22 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
263
278
|
last_trade_proposal.portfolio.assets.filter(
|
|
264
279
|
date=last_trade_proposal.trade_date
|
|
265
280
|
).delete() # we delete the existing position and we reapply the trade proposal
|
|
266
|
-
if
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
logger.info("
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
281
|
+
if last_trade_proposal.status == TradeProposal.Status.APPROVED:
|
|
282
|
+
logger.info("Reverting trade proposal ...")
|
|
283
|
+
last_trade_proposal.revert()
|
|
284
|
+
if last_trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
285
|
+
if self.rebalancing_model: # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
|
|
286
|
+
logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
|
|
287
|
+
with suppress(
|
|
288
|
+
ValidationError
|
|
289
|
+
): # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
|
|
290
|
+
self.reset_trades()
|
|
291
|
+
logger.info("Submitting trade proposal ...")
|
|
292
|
+
last_trade_proposal.submit()
|
|
293
|
+
if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
|
|
294
|
+
logger.info("Approving trade proposal ...")
|
|
295
|
+
last_trade_proposal.approve(replay=False)
|
|
296
|
+
last_trade_proposal.save()
|
|
277
297
|
next_trade_proposal = last_trade_proposal.next_trade_proposal
|
|
278
298
|
next_trade_date = (
|
|
279
299
|
next_trade_proposal.trade_date - timedelta(days=1) if next_trade_proposal else date.today()
|
|
@@ -283,14 +303,79 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
283
303
|
)
|
|
284
304
|
last_trade_proposal = overriding_trade_proposal or next_trade_proposal
|
|
285
305
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
306
|
+
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal | None:
|
|
307
|
+
"""
|
|
308
|
+
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
309
|
+
|
|
310
|
+
This method calculates the estimated shares by dividing the trade's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
weight (Decimal): The weight of the trade.
|
|
314
|
+
underlying_quote (Instrument): The underlying instrument for the trade.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
# Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
321
|
+
quote_price = Decimal(underlying_quote.get_price(self.trade_date))
|
|
322
|
+
|
|
323
|
+
# Calculate the trade's total value in the portfolio's currency
|
|
324
|
+
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
325
|
+
|
|
326
|
+
# Convert the quote price to the portfolio's currency
|
|
327
|
+
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
328
|
+
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
290
329
|
)
|
|
291
|
-
|
|
330
|
+
|
|
331
|
+
# If the price is valid, calculate and return the estimated shares
|
|
292
332
|
if price_fx_portfolio:
|
|
293
333
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
334
|
+
except Exception:
|
|
335
|
+
# Suppress any ValueError and return None if the calculation fails
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def get_estimated_target_cash(self, currency: Currency) -> tuple[Decimal, Decimal]:
|
|
339
|
+
"""
|
|
340
|
+
Estimates the target cash weight and shares for a trade proposal.
|
|
341
|
+
|
|
342
|
+
This method calculates the target cash weight by summing the weights of cash trades and adding any leftover weight from non-cash trades. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
currency (Currency): The currency for the target currency component
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
349
|
+
"""
|
|
350
|
+
# Retrieve trades with base information
|
|
351
|
+
trades = self.trades.all().annotate_base_info()
|
|
352
|
+
|
|
353
|
+
# Calculate the target cash weight from cash trades
|
|
354
|
+
target_cash_weight = trades.filter(
|
|
355
|
+
underlying_instrument__is_cash=True, underlying_instrument__currency=currency
|
|
356
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
357
|
+
# if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
|
|
358
|
+
if currency == self.portfolio.currency:
|
|
359
|
+
# Calculate the total target weight of all trades
|
|
360
|
+
total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
361
|
+
|
|
362
|
+
# Add any leftover weight as cash
|
|
363
|
+
target_cash_weight += Decimal(1) - total_target_weight
|
|
364
|
+
|
|
365
|
+
# Initialize target shares to zero
|
|
366
|
+
total_target_shares = Decimal(0)
|
|
367
|
+
|
|
368
|
+
# If the portfolio is not only weighting-based, estimate the target shares for the cash component
|
|
369
|
+
if not self.portfolio.only_weighting:
|
|
370
|
+
# Get or create a cash component for the portfolio's currency
|
|
371
|
+
cash_component = Cash.objects.get_or_create(
|
|
372
|
+
currency=currency, defaults={"is_cash": True, "name": currency.title}
|
|
373
|
+
)[0]
|
|
374
|
+
|
|
375
|
+
# Estimate the target shares for the cash component
|
|
376
|
+
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
|
|
377
|
+
|
|
378
|
+
return target_cash_weight, total_target_shares
|
|
294
379
|
|
|
295
380
|
# Start FSM logics
|
|
296
381
|
|
|
@@ -315,9 +400,26 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
315
400
|
)
|
|
316
401
|
def submit(self, by=None, description=None, **kwargs):
|
|
317
402
|
self.trades.update(comment="", status=Trade.Status.DRAFT)
|
|
403
|
+
self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
|
|
318
404
|
for trade in self.trades.all():
|
|
319
405
|
trade.submit()
|
|
320
406
|
trade.save()
|
|
407
|
+
|
|
408
|
+
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
409
|
+
cash_target_cash_weight, cash_target_cash_shares = self.get_estimated_target_cash(self.portfolio.currency)
|
|
410
|
+
if cash_target_cash_weight:
|
|
411
|
+
cash_component = Cash.objects.get_or_create(
|
|
412
|
+
currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
|
|
413
|
+
)[0]
|
|
414
|
+
self.trades.update_or_create(
|
|
415
|
+
underlying_instrument=cash_component,
|
|
416
|
+
defaults={
|
|
417
|
+
"status": Trade.Status.SUBMIT,
|
|
418
|
+
"weighting": cash_target_cash_weight,
|
|
419
|
+
"shares": cash_target_cash_shares,
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
|
|
321
423
|
self.evaluate_active_rules(
|
|
322
424
|
self.trade_date, self.validated_trading_service.target_portfolio, asynchronously=True
|
|
323
425
|
)
|
|
@@ -348,7 +450,7 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
348
450
|
|
|
349
451
|
@property
|
|
350
452
|
def can_be_approved_or_denied(self):
|
|
351
|
-
return self.
|
|
453
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
352
454
|
|
|
353
455
|
@transition(
|
|
354
456
|
field=status,
|
|
@@ -370,7 +472,7 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
370
472
|
)
|
|
371
473
|
},
|
|
372
474
|
)
|
|
373
|
-
def approve(self, by=None, description=None,
|
|
475
|
+
def approve(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
374
476
|
# We validate trade which will create or update the initial asset positions
|
|
375
477
|
if not self.portfolio.can_be_rebalanced:
|
|
376
478
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -379,6 +481,8 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
379
481
|
for trade in self.trades.all():
|
|
380
482
|
trade.execute()
|
|
381
483
|
trade.save()
|
|
484
|
+
if replay and self.portfolio.is_manageable:
|
|
485
|
+
replay_as_task.delay(self.id)
|
|
382
486
|
|
|
383
487
|
def can_approve(self):
|
|
384
488
|
errors = dict()
|
|
@@ -390,7 +494,7 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
390
494
|
errors["portfolio"] = (
|
|
391
495
|
"The portfolio needs to be a model portfolio in order to approve this trade proposal manually"
|
|
392
496
|
)
|
|
393
|
-
if self.
|
|
497
|
+
if self.has_non_successful_checks:
|
|
394
498
|
errors["non_field_errors"] = "The pre trades rules did not passed successfully"
|
|
395
499
|
return errors
|
|
396
500
|
|
|
@@ -432,7 +536,8 @@ class TradeProposal(RiskCheckMixin, WBModel):
|
|
|
432
536
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
433
537
|
user.profile, portfolio=instance.portfolio
|
|
434
538
|
)
|
|
435
|
-
and instance.has_all_check_completed
|
|
539
|
+
and instance.has_all_check_completed
|
|
540
|
+
or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
|
|
436
541
|
custom={
|
|
437
542
|
"_transition_button": ActionButton(
|
|
438
543
|
method=RequestType.PATCH,
|
|
@@ -136,6 +136,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
136
136
|
REDEMPTION = "REDEMPTION", "Redemption"
|
|
137
137
|
BUY = "BUY", "Buy"
|
|
138
138
|
SELL = "SELL", "Sell"
|
|
139
|
+
NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
|
|
139
140
|
|
|
140
141
|
external_identifier2 = models.CharField(
|
|
141
142
|
max_length=255,
|
|
@@ -224,7 +225,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
224
225
|
source=Status.DRAFT,
|
|
225
226
|
target=GET_STATE(
|
|
226
227
|
lambda self, **kwargs: (
|
|
227
|
-
self.Status.SUBMIT if self.
|
|
228
|
+
self.Status.SUBMIT if self.last_underlying_quote_price is not None else self.Status.FAILED
|
|
228
229
|
),
|
|
229
230
|
states=[Status.SUBMIT, Status.FAILED],
|
|
230
231
|
),
|
|
@@ -245,7 +246,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
245
246
|
on_error="FAILED",
|
|
246
247
|
)
|
|
247
248
|
def submit(self, by=None, description=None, **kwargs):
|
|
248
|
-
if not self.
|
|
249
|
+
if not self.last_underlying_quote_price:
|
|
249
250
|
self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
250
251
|
|
|
251
252
|
def can_submit(self):
|
|
@@ -263,13 +264,15 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
263
264
|
self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
264
265
|
|
|
265
266
|
@cached_property
|
|
266
|
-
def
|
|
267
|
+
def last_underlying_quote_price(self) -> InstrumentPrice | None:
|
|
267
268
|
try:
|
|
269
|
+
# we try t0 first
|
|
268
270
|
return InstrumentPrice.objects.filter_only_valid_prices().get(
|
|
269
|
-
instrument=self.underlying_instrument, date=self.
|
|
271
|
+
instrument=self.underlying_instrument, date=self.transaction_date
|
|
270
272
|
)
|
|
271
273
|
except InstrumentPrice.DoesNotExist:
|
|
272
274
|
with suppress(InstrumentPrice.DoesNotExist):
|
|
275
|
+
# we fall back to the latest price before t0
|
|
273
276
|
return (
|
|
274
277
|
InstrumentPrice.objects.filter_only_valid_prices()
|
|
275
278
|
.filter(instrument=self.underlying_instrument, date__lte=self.value_date)
|
|
@@ -296,7 +299,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
296
299
|
},
|
|
297
300
|
)
|
|
298
301
|
def execute(self, **kwargs):
|
|
299
|
-
if self.
|
|
302
|
+
if self.last_underlying_quote_price:
|
|
300
303
|
asset, created = AssetPosition.unannotated_objects.update_or_create(
|
|
301
304
|
underlying_quote=self.underlying_instrument,
|
|
302
305
|
portfolio_created=None,
|
|
@@ -305,9 +308,9 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
305
308
|
defaults={
|
|
306
309
|
"initial_currency_fx_rate": self.currency_fx_rate,
|
|
307
310
|
"weighting": self._target_weight,
|
|
308
|
-
"initial_price": self.
|
|
311
|
+
"initial_price": self.last_underlying_quote_price.net_value,
|
|
309
312
|
"initial_shares": None,
|
|
310
|
-
"underlying_quote_price": self.
|
|
313
|
+
"underlying_quote_price": self.last_underlying_quote_price,
|
|
311
314
|
"asset_valuation_date": self.transaction_date,
|
|
312
315
|
"currency": self.currency,
|
|
313
316
|
"is_estimated": False,
|
|
@@ -316,7 +319,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
316
319
|
asset.set_weighting(self._target_weight)
|
|
317
320
|
|
|
318
321
|
def can_execute(self):
|
|
319
|
-
if not self.
|
|
322
|
+
if not self.last_underlying_quote_price:
|
|
320
323
|
return {"underlying_instrument": "Cannot execute a trade without a valid quote price"}
|
|
321
324
|
if not self.portfolio.is_manageable:
|
|
322
325
|
return {"portfolio": "The portfolio needs to be a model portfolio in order to execute this trade manually"}
|
|
@@ -487,14 +490,15 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
487
490
|
super().__init__(*args, **kwargs)
|
|
488
491
|
if target_weight is not None: # if target weight is provided, we guess the corresponding weighting
|
|
489
492
|
self.weighting = Decimal(target_weight) - self._effective_weight
|
|
493
|
+
self._set_transaction_subtype()
|
|
490
494
|
|
|
491
495
|
def save(self, *args, **kwargs):
|
|
492
496
|
if self.trade_proposal:
|
|
493
497
|
self.portfolio = self.trade_proposal.portfolio
|
|
494
498
|
self.transaction_date = self.trade_proposal.trade_date
|
|
495
499
|
self.value_date = self.trade_proposal.last_effective_date
|
|
496
|
-
if self.
|
|
497
|
-
self.shares = self.
|
|
500
|
+
if not self.portfolio.only_weighting:
|
|
501
|
+
self.shares = self.trade_proposal.get_estimated_shares(self.weighting, self.underlying_instrument)
|
|
498
502
|
|
|
499
503
|
if not self.custodian and self.bank:
|
|
500
504
|
self.custodian = Custodian.get_by_mapping(self.bank)
|
|
@@ -512,21 +516,9 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
512
516
|
self.total_value_gross = self.price_gross * self.shares
|
|
513
517
|
self.transaction_type = Transaction.Type.TRADE
|
|
514
518
|
|
|
515
|
-
if self.transaction_subtype is None:
|
|
519
|
+
if self.transaction_subtype is None or self.trade_proposal:
|
|
516
520
|
# if subtype not provided, we extract it automatically from the existing data.
|
|
517
|
-
|
|
518
|
-
if self.shares is not None:
|
|
519
|
-
if self.shares > 0:
|
|
520
|
-
self.transaction_subtype = Trade.Type.SUBSCRIPTION
|
|
521
|
-
elif self.shares < 0:
|
|
522
|
-
self.transaction_subtype = Trade.Type.REDEMPTION
|
|
523
|
-
elif self.weighting is not None:
|
|
524
|
-
if self.weighting > 0:
|
|
525
|
-
self.transaction_subtype = Trade.Type.BUY
|
|
526
|
-
elif self.weighting < 0:
|
|
527
|
-
self.transaction_subtype = Trade.Type.SELL
|
|
528
|
-
else:
|
|
529
|
-
self.transaction_subtype = Trade.Type.REBALANCE
|
|
521
|
+
self._set_transaction_subtype()
|
|
530
522
|
if self.id and hasattr(self, "claims"):
|
|
531
523
|
self.claimed_shares = self.trade.claims.filter(status="APPROVED").aggregate(s=Sum("shares"))[
|
|
532
524
|
"s"
|
|
@@ -535,6 +527,23 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
535
527
|
self.marked_as_internal = True
|
|
536
528
|
super().save(*args, **kwargs)
|
|
537
529
|
|
|
530
|
+
def _set_transaction_subtype(self):
|
|
531
|
+
if self.weighting == 0:
|
|
532
|
+
self.transaction_subtype = Trade.Type.NO_CHANGE
|
|
533
|
+
if self.underlying_instrument.instrument_type.key == "product":
|
|
534
|
+
if self.shares is not None:
|
|
535
|
+
if self.shares > 0:
|
|
536
|
+
self.transaction_subtype = Trade.Type.SUBSCRIPTION
|
|
537
|
+
elif self.shares < 0:
|
|
538
|
+
self.transaction_subtype = Trade.Type.REDEMPTION
|
|
539
|
+
elif self.weighting is not None:
|
|
540
|
+
if self.weighting > 0:
|
|
541
|
+
self.transaction_subtype = Trade.Type.BUY
|
|
542
|
+
elif self.weighting < 0:
|
|
543
|
+
self.transaction_subtype = Trade.Type.SELL
|
|
544
|
+
else:
|
|
545
|
+
self.transaction_subtype = Trade.Type.REBALANCE
|
|
546
|
+
|
|
538
547
|
def get_transaction_subtype(self) -> str:
|
|
539
548
|
"""
|
|
540
549
|
Return the expected transaction subtype based n
|
|
@@ -560,7 +569,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
560
569
|
underlying_instrument=self.underlying_instrument.id,
|
|
561
570
|
effective_weight=self._effective_weight,
|
|
562
571
|
target_weight=self._target_weight,
|
|
563
|
-
instrument_type=self.underlying_instrument.security_instrument_type,
|
|
572
|
+
instrument_type=self.underlying_instrument.security_instrument_type.id,
|
|
564
573
|
currency=self.underlying_instrument.currency,
|
|
565
574
|
date=self.transaction_date,
|
|
566
575
|
)
|
|
@@ -62,7 +62,7 @@ class TradingService:
|
|
|
62
62
|
Test the given value against all the validators on the field,
|
|
63
63
|
and either raise a `ValidationError` or simply return.
|
|
64
64
|
"""
|
|
65
|
-
TradeBatch(validated_trades).validate()
|
|
65
|
+
# TradeBatch(validated_trades).validate()
|
|
66
66
|
if self.effective_portfolio:
|
|
67
67
|
for trade in validated_trades:
|
|
68
68
|
if (
|
wbportfolio/pms/typing.py
CHANGED
|
@@ -70,6 +70,9 @@ class Portfolio:
|
|
|
70
70
|
def to_df(self):
|
|
71
71
|
return pd.DataFrame([asdict(pos) for pos in self.positions])
|
|
72
72
|
|
|
73
|
+
def to_dict(self) -> dict[int, Decimal]:
|
|
74
|
+
return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
|
|
75
|
+
|
|
73
76
|
def __len__(self):
|
|
74
77
|
return len(self.positions)
|
|
75
78
|
|
wbportfolio/preferences.py
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
from dynamic_preferences.registries import global_preferences_registry
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
def get_monthly_nnm_target(*args, **kwargs):
|
|
4
|
+
def get_monthly_nnm_target(*args, **kwargs) -> int:
|
|
5
5
|
global_preferences = global_preferences_registry.manager()
|
|
6
6
|
return global_preferences["wbportfolio__monthly_nnm_target"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_product_termination_notice_period(*args, **kwargs) -> int:
|
|
10
|
+
global_preferences = global_preferences_registry.manager()
|
|
11
|
+
return global_preferences["wbportfolio__product_termination_notice_period"]
|
|
@@ -2,6 +2,7 @@ from decimal import Decimal
|
|
|
2
2
|
|
|
3
3
|
from django.core.exceptions import ObjectDoesNotExist
|
|
4
4
|
|
|
5
|
+
from wbportfolio.models import Trade
|
|
5
6
|
from wbportfolio.pms.typing import Portfolio, Position
|
|
6
7
|
from wbportfolio.rebalancing.base import AbstractRebalancingModel
|
|
7
8
|
from wbportfolio.rebalancing.decorators import register
|
|
@@ -11,11 +12,23 @@ from wbportfolio.rebalancing.decorators import register
|
|
|
11
12
|
class CompositeRebalancing(AbstractRebalancingModel):
|
|
12
13
|
@property
|
|
13
14
|
def base_assets(self) -> dict[int, Decimal]:
|
|
15
|
+
"""
|
|
16
|
+
Return a dictionary representation (instrument_id: target weight) of this trade proposal
|
|
17
|
+
Returns:
|
|
18
|
+
A dictionary representation
|
|
19
|
+
|
|
20
|
+
"""
|
|
14
21
|
try:
|
|
15
22
|
latest_trade_proposal = self.portfolio.trade_proposals.filter(
|
|
16
23
|
status="APPROVED", trade_date__lte=self.trade_date
|
|
17
24
|
).latest("trade_date")
|
|
18
|
-
return
|
|
25
|
+
return {
|
|
26
|
+
v["underlying_instrument"]: v["target_weight"]
|
|
27
|
+
for v in latest_trade_proposal.trades.all()
|
|
28
|
+
.annotate_base_info()
|
|
29
|
+
.filter(status=Trade.Status.EXECUTED)
|
|
30
|
+
.values("underlying_instrument", "target_weight")
|
|
31
|
+
}
|
|
19
32
|
except ObjectDoesNotExist:
|
|
20
33
|
return dict()
|
|
21
34
|
|
|
@@ -3,6 +3,7 @@ from typing import Generator
|
|
|
3
3
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from django.contrib.contenttypes.models import ContentType
|
|
6
|
+
from django.contrib.humanize.templatetags.humanize import intcomma
|
|
6
7
|
from django.db import models
|
|
7
8
|
from wbcompliance.models.risk_management import backend
|
|
8
9
|
from wbcompliance.models.risk_management.dispatch import register
|
|
@@ -148,15 +149,22 @@ class RuleBackend(backend.AbstractRuleBackend):
|
|
|
148
149
|
report_details = {
|
|
149
150
|
"Period": f"{cts_generator.start_date:%d.%m.%Y} - {cts_generator.end_date:%d.%m.%Y}",
|
|
150
151
|
}
|
|
152
|
+
|
|
153
|
+
# create report detail template
|
|
151
154
|
color = "red" if percentage < 0 else "green"
|
|
155
|
+
number_prefix = ""
|
|
156
|
+
|
|
152
157
|
if self.field == "AUM":
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
158
|
+
key = "AUM Change"
|
|
159
|
+
number_prefix = "$"
|
|
156
160
|
else:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
key = "Shares Change"
|
|
162
|
+
|
|
163
|
+
template = f'{number_prefix}{{}} → {number_prefix}{{}} <span style="color:{color}"><strong>Δ {number_prefix}{{}}</strong></span>'
|
|
164
|
+
start = intcomma(int(start_df.loc[breached_obj_id].round(0)))
|
|
165
|
+
end = intcomma(int(end_df.loc[breached_obj_id].round(0)))
|
|
166
|
+
diff = intcomma(int((end_df.loc[breached_obj_id] - start_df.loc[breached_obj_id]).round(0)))
|
|
167
|
+
report_details[key] = template.format(start, end, diff)
|
|
160
168
|
report_details["Group By"] = self.group_by.value
|
|
161
169
|
yield backend.IncidentResult(
|
|
162
170
|
breached_object=breached_obj,
|