wbportfolio 1.48.0__py2.py3-none-any.whl → 1.49.1__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/factories/__init__.py +1 -3
- wbportfolio/factories/dividends.py +1 -0
- 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/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +1 -1
- wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -1
- wbportfolio/import_export/handlers/trade.py +13 -2
- wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -1
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py +126 -0
- wbportfolio/models/portfolio.py +15 -16
- wbportfolio/models/transactions/claim.py +8 -7
- wbportfolio/models/transactions/dividends.py +3 -20
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +163 -63
- wbportfolio/models/transactions/trades.py +24 -22
- wbportfolio/models/transactions/transactions.py +37 -37
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tests/models/test_portfolios.py +2 -2
- 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/assets.py +0 -11
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/products.py +0 -13
- wbportfolio/viewsets/configs/display/trades.py +24 -17
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/configs/endpoints/transactions.py +6 -0
- wbportfolio/viewsets/portfolios.py +39 -8
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- wbportfolio/viewsets/transactions/trades.py +105 -13
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/METADATA +1 -1
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/RECORD +46 -43
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/WHEEL +0 -0
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,15 +8,17 @@ from celery import shared_task
|
|
|
8
8
|
from django.core.exceptions import ValidationError
|
|
9
9
|
from django.db import models
|
|
10
10
|
from django.utils.functional import cached_property
|
|
11
|
+
from django.utils.translation import gettext_lazy as _
|
|
11
12
|
from django_fsm import FSMField, transition
|
|
12
13
|
from pandas._libs.tslibs.offsets import BDay
|
|
13
14
|
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
15
|
+
from wbcore.contrib.currency.models import Currency
|
|
14
16
|
from wbcore.contrib.icons import WBIcon
|
|
15
17
|
from wbcore.enums import RequestType
|
|
16
18
|
from wbcore.metadata.configs.buttons import ActionButton
|
|
17
19
|
from wbcore.models import WBModel
|
|
18
20
|
from wbcore.utils.models import CloneMixin
|
|
19
|
-
from wbfdm.models.instruments.instruments import Instrument
|
|
21
|
+
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
20
22
|
|
|
21
23
|
from wbportfolio.models.roles import PortfolioRole
|
|
22
24
|
from wbportfolio.pms.trading import TradingService
|
|
@@ -89,6 +91,10 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
89
91
|
def check_evaluation_date(self):
|
|
90
92
|
return self.trade_date
|
|
91
93
|
|
|
94
|
+
@cached_property
|
|
95
|
+
def portfolio_total_asset_value(self) -> Decimal:
|
|
96
|
+
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
97
|
+
|
|
92
98
|
@cached_property
|
|
93
99
|
def validated_trading_service(self) -> TradingService:
|
|
94
100
|
"""
|
|
@@ -96,8 +102,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
96
102
|
"""
|
|
97
103
|
return TradingService(
|
|
98
104
|
self.trade_date,
|
|
99
|
-
effective_portfolio=self.portfolio._build_dto(self.
|
|
100
|
-
|
|
105
|
+
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
106
|
+
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
101
107
|
)
|
|
102
108
|
|
|
103
109
|
@cached_property
|
|
@@ -150,9 +156,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
150
156
|
Returns:
|
|
151
157
|
DTO trade object
|
|
152
158
|
"""
|
|
153
|
-
return (
|
|
154
|
-
TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()])) if self.trades.exists() else None
|
|
155
|
-
)
|
|
159
|
+
return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
|
|
156
160
|
|
|
157
161
|
# Start tools methods
|
|
158
162
|
def _clone(self, **kwargs) -> SelfTradeProposal:
|
|
@@ -197,7 +201,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
197
201
|
with suppress(Trade.DoesNotExist):
|
|
198
202
|
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
199
203
|
trade.weighting = round(trade_dto.delta_weight, 6)
|
|
200
|
-
trade.shares = self.estimate_shares(trade)
|
|
201
204
|
trade.save()
|
|
202
205
|
total_target_weight += trade._target_weight
|
|
203
206
|
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
@@ -208,7 +211,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
208
211
|
biggest_trade.weighting -= quantize_error
|
|
209
212
|
biggest_trade.save()
|
|
210
213
|
|
|
211
|
-
def
|
|
214
|
+
def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
|
|
212
215
|
if self.rebalancing_model:
|
|
213
216
|
params = {}
|
|
214
217
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -217,50 +220,55 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
217
220
|
return self.rebalancing_model.get_target_portfolio(
|
|
218
221
|
self.portfolio, self.trade_date, self.last_effective_date, **params
|
|
219
222
|
)
|
|
223
|
+
if self.trades.exists():
|
|
224
|
+
return self._build_dto().convert_to_portfolio()
|
|
220
225
|
# Return the current portfolio by default
|
|
221
226
|
return self.portfolio._build_dto(self.last_effective_date)
|
|
222
227
|
|
|
223
|
-
def reset_trades(self, target_portfolio: PortfolioDTO | None = None):
|
|
228
|
+
def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
|
|
224
229
|
"""
|
|
225
230
|
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
226
231
|
"""
|
|
227
|
-
if self.status != TradeProposal.Status.DRAFT:
|
|
228
|
-
raise ValueError("Cannot reset non-draft trade proposal. Revert this trade proposal first.")
|
|
229
232
|
# delete all existing trades
|
|
230
|
-
self.trades.all().delete()
|
|
231
233
|
last_effective_date = self.last_effective_date
|
|
232
234
|
# Get effective and target portfolio
|
|
233
235
|
effective_portfolio = self.portfolio._build_dto(last_effective_date)
|
|
234
236
|
if not target_portfolio:
|
|
235
|
-
target_portfolio = self.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)
|
|
243
|
-
service.normalize()
|
|
244
|
-
service.is_valid()
|
|
245
|
-
for trade_dto in service.validated_trades:
|
|
246
|
-
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
247
|
-
currency_fx_rate = instrument.currency.convert(
|
|
248
|
-
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
249
|
-
)
|
|
250
|
-
trade = Trade(
|
|
251
|
-
underlying_instrument=instrument,
|
|
252
|
-
transaction_subtype=Trade.Type.BUY if trade_dto.delta_weight > 0 else Trade.Type.SELL,
|
|
253
|
-
currency=instrument.currency,
|
|
254
|
-
value_date=last_effective_date,
|
|
255
|
-
transaction_date=self.trade_date,
|
|
256
|
-
trade_proposal=self,
|
|
257
|
-
portfolio=self.portfolio,
|
|
258
|
-
weighting=trade_dto.delta_weight,
|
|
259
|
-
status=Trade.Status.DRAFT,
|
|
260
|
-
currency_fx_rate=currency_fx_rate,
|
|
237
|
+
target_portfolio = self._get_default_target_portfolio()
|
|
238
|
+
|
|
239
|
+
if target_portfolio:
|
|
240
|
+
service = TradingService(
|
|
241
|
+
self.trade_date,
|
|
242
|
+
effective_portfolio=effective_portfolio,
|
|
243
|
+
target_portfolio=target_portfolio,
|
|
261
244
|
)
|
|
262
|
-
|
|
263
|
-
|
|
245
|
+
if validate_trade:
|
|
246
|
+
service.normalize()
|
|
247
|
+
service.is_valid()
|
|
248
|
+
trades = service.validated_trades
|
|
249
|
+
else:
|
|
250
|
+
trades = service.trades_batch.trades_map.values()
|
|
251
|
+
for trade_dto in trades:
|
|
252
|
+
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
253
|
+
currency_fx_rate = instrument.currency.convert(
|
|
254
|
+
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
255
|
+
)
|
|
256
|
+
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
257
|
+
try:
|
|
258
|
+
trade = self.trades.get(underlying_instrument=instrument)
|
|
259
|
+
except Trade.DoesNotExist:
|
|
260
|
+
trade = Trade(
|
|
261
|
+
underlying_instrument=instrument,
|
|
262
|
+
currency=instrument.currency,
|
|
263
|
+
value_date=last_effective_date,
|
|
264
|
+
transaction_date=self.trade_date,
|
|
265
|
+
trade_proposal=self,
|
|
266
|
+
portfolio=self.portfolio,
|
|
267
|
+
weighting=trade_dto.delta_weight,
|
|
268
|
+
status=Trade.Status.DRAFT,
|
|
269
|
+
currency_fx_rate=currency_fx_rate,
|
|
270
|
+
)
|
|
271
|
+
trade.save()
|
|
264
272
|
|
|
265
273
|
def replay(self):
|
|
266
274
|
last_trade_proposal = self
|
|
@@ -275,12 +283,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
275
283
|
if last_trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
276
284
|
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
|
|
277
285
|
logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
|
|
278
|
-
|
|
286
|
+
with suppress(
|
|
287
|
+
ValidationError
|
|
288
|
+
): # 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
|
|
289
|
+
self.reset_trades()
|
|
279
290
|
logger.info("Submitting trade proposal ...")
|
|
280
291
|
last_trade_proposal.submit()
|
|
281
292
|
if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
|
|
282
293
|
logger.info("Approving trade proposal ...")
|
|
283
|
-
last_trade_proposal.approve()
|
|
294
|
+
last_trade_proposal.approve(replay=False)
|
|
284
295
|
last_trade_proposal.save()
|
|
285
296
|
next_trade_proposal = last_trade_proposal.next_trade_proposal
|
|
286
297
|
next_trade_date = (
|
|
@@ -291,14 +302,79 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
291
302
|
)
|
|
292
303
|
last_trade_proposal = overriding_trade_proposal or next_trade_proposal
|
|
293
304
|
|
|
294
|
-
def
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
305
|
+
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal:
|
|
306
|
+
"""
|
|
307
|
+
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
weight (Decimal): The weight of the trade.
|
|
313
|
+
underlying_quote (Instrument): The underlying instrument for the trade.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
# 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
|
|
320
|
+
quote_price = Decimal(underlying_quote.get_price(self.trade_date))
|
|
321
|
+
|
|
322
|
+
# Calculate the trade's total value in the portfolio's currency
|
|
323
|
+
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
324
|
+
|
|
325
|
+
# Convert the quote price to the portfolio's currency
|
|
326
|
+
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
327
|
+
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
298
328
|
)
|
|
299
|
-
|
|
329
|
+
|
|
330
|
+
# If the price is valid, calculate and return the estimated shares
|
|
300
331
|
if price_fx_portfolio:
|
|
301
332
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
333
|
+
except Exception:
|
|
334
|
+
raise ValueError("We couldn't estimate the number of shares")
|
|
335
|
+
|
|
336
|
+
def get_estimated_target_cash(self, currency: Currency) -> tuple[Decimal, Decimal]:
|
|
337
|
+
"""
|
|
338
|
+
Estimates the target cash weight and shares for a trade proposal.
|
|
339
|
+
|
|
340
|
+
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.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
currency (Currency): The currency for the target currency component
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
347
|
+
"""
|
|
348
|
+
# Retrieve trades with base information
|
|
349
|
+
trades = self.trades.all().annotate_base_info()
|
|
350
|
+
|
|
351
|
+
# Calculate the target cash weight from cash trades
|
|
352
|
+
target_cash_weight = trades.filter(
|
|
353
|
+
underlying_instrument__is_cash=True, underlying_instrument__currency=currency
|
|
354
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
355
|
+
# if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
|
|
356
|
+
if currency == self.portfolio.currency:
|
|
357
|
+
# Calculate the total target weight of all trades
|
|
358
|
+
total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
359
|
+
|
|
360
|
+
# Add any leftover weight as cash
|
|
361
|
+
target_cash_weight += Decimal(1) - total_target_weight
|
|
362
|
+
|
|
363
|
+
# Initialize target shares to zero
|
|
364
|
+
total_target_shares = Decimal(0)
|
|
365
|
+
|
|
366
|
+
# If the portfolio is not only weighting-based, estimate the target shares for the cash component
|
|
367
|
+
if not self.portfolio.only_weighting:
|
|
368
|
+
# Get or create a cash component for the portfolio's currency
|
|
369
|
+
cash_component = Cash.objects.get_or_create(
|
|
370
|
+
currency=currency, defaults={"is_cash": True, "name": currency.title}
|
|
371
|
+
)[0]
|
|
372
|
+
|
|
373
|
+
# Estimate the target shares for the cash component
|
|
374
|
+
with suppress(ValueError):
|
|
375
|
+
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
|
|
376
|
+
|
|
377
|
+
return target_cash_weight, total_target_shares
|
|
302
378
|
|
|
303
379
|
# Start FSM logics
|
|
304
380
|
|
|
@@ -323,9 +399,26 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
323
399
|
)
|
|
324
400
|
def submit(self, by=None, description=None, **kwargs):
|
|
325
401
|
self.trades.update(comment="", status=Trade.Status.DRAFT)
|
|
402
|
+
self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
|
|
326
403
|
for trade in self.trades.all():
|
|
327
404
|
trade.submit()
|
|
328
405
|
trade.save()
|
|
406
|
+
|
|
407
|
+
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
408
|
+
cash_target_cash_weight, cash_target_cash_shares = self.get_estimated_target_cash(self.portfolio.currency)
|
|
409
|
+
if cash_target_cash_weight:
|
|
410
|
+
cash_component = Cash.objects.get_or_create(
|
|
411
|
+
currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
|
|
412
|
+
)[0]
|
|
413
|
+
self.trades.update_or_create(
|
|
414
|
+
underlying_instrument=cash_component,
|
|
415
|
+
defaults={
|
|
416
|
+
"status": Trade.Status.SUBMIT,
|
|
417
|
+
"weighting": cash_target_cash_weight,
|
|
418
|
+
"shares": cash_target_cash_shares,
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
|
|
329
422
|
self.evaluate_active_rules(
|
|
330
423
|
self.trade_date, self.validated_trading_service.target_portfolio, asynchronously=True
|
|
331
424
|
)
|
|
@@ -334,7 +427,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
334
427
|
errors = dict()
|
|
335
428
|
errors_list = []
|
|
336
429
|
if self.trades.exists() and self.trades.exclude(status=Trade.Status.DRAFT).exists():
|
|
337
|
-
errors_list.append("All trades need to be draft before submitting")
|
|
430
|
+
errors_list.append(_("All trades need to be draft before submitting"))
|
|
338
431
|
service = self.validated_trading_service
|
|
339
432
|
try:
|
|
340
433
|
service.is_valid(ignore_error=True)
|
|
@@ -343,7 +436,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
343
436
|
# "There is no change detected in this trade proposal. Please submit at last one valid trade"
|
|
344
437
|
# )
|
|
345
438
|
if len(service.validated_trades) == 0:
|
|
346
|
-
errors_list.append("There is no valid trade on this proposal")
|
|
439
|
+
errors_list.append(_("There is no valid trade on this proposal"))
|
|
347
440
|
if service.errors:
|
|
348
441
|
errors_list.extend(service.errors)
|
|
349
442
|
if errors_list:
|
|
@@ -356,7 +449,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
356
449
|
|
|
357
450
|
@property
|
|
358
451
|
def can_be_approved_or_denied(self):
|
|
359
|
-
return self.
|
|
452
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
360
453
|
|
|
361
454
|
@transition(
|
|
362
455
|
field=status,
|
|
@@ -378,7 +471,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
378
471
|
)
|
|
379
472
|
},
|
|
380
473
|
)
|
|
381
|
-
def approve(self, by=None, description=None,
|
|
474
|
+
def approve(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
382
475
|
# We validate trade which will create or update the initial asset positions
|
|
383
476
|
if not self.portfolio.can_be_rebalanced:
|
|
384
477
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -387,19 +480,23 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
387
480
|
for trade in self.trades.all():
|
|
388
481
|
trade.execute()
|
|
389
482
|
trade.save()
|
|
483
|
+
if replay and self.portfolio.is_manageable:
|
|
484
|
+
replay_as_task.delay(self.id)
|
|
390
485
|
|
|
391
486
|
def can_approve(self):
|
|
392
487
|
errors = dict()
|
|
393
488
|
if not self.portfolio.can_be_rebalanced:
|
|
394
|
-
errors["non_field_errors"] = "The portfolio does not allow manual rebalanced"
|
|
489
|
+
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
395
490
|
if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
|
|
396
|
-
errors["non_field_errors"] =
|
|
491
|
+
errors["non_field_errors"] = [
|
|
492
|
+
_("At least one trade needs to be submitted to be able to approve this proposal")
|
|
493
|
+
]
|
|
397
494
|
if not self.portfolio.can_be_rebalanced:
|
|
398
|
-
errors["portfolio"] =
|
|
399
|
-
"The portfolio needs to be a model portfolio in order to approve this trade proposal manually"
|
|
400
|
-
|
|
401
|
-
if self.
|
|
402
|
-
errors["non_field_errors"] = "The pre trades rules did not passed successfully"
|
|
495
|
+
errors["portfolio"] = [
|
|
496
|
+
[_("The portfolio needs to be a model portfolio in order to approve this trade proposal manually")]
|
|
497
|
+
]
|
|
498
|
+
if self.has_non_successful_checks:
|
|
499
|
+
errors["non_field_errors"] = [_("The pre trades rules did not passed successfully")]
|
|
403
500
|
return errors
|
|
404
501
|
|
|
405
502
|
@transition(
|
|
@@ -430,7 +527,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
430
527
|
def can_deny(self):
|
|
431
528
|
errors = dict()
|
|
432
529
|
if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
|
|
433
|
-
errors["non_field_errors"] =
|
|
530
|
+
errors["non_field_errors"] = [
|
|
531
|
+
_("At least one trade needs to be submitted to be able to deny this proposal")
|
|
532
|
+
]
|
|
434
533
|
return errors
|
|
435
534
|
|
|
436
535
|
@transition(
|
|
@@ -440,7 +539,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
440
539
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
441
540
|
user.profile, portfolio=instance.portfolio
|
|
442
541
|
)
|
|
443
|
-
and instance.has_all_check_completed
|
|
542
|
+
and instance.has_all_check_completed
|
|
543
|
+
or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
|
|
444
544
|
custom={
|
|
445
545
|
"_transition_button": ActionButton(
|
|
446
546
|
method=RequestType.PATCH,
|
|
@@ -492,9 +592,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
492
592
|
def can_revert(self):
|
|
493
593
|
errors = dict()
|
|
494
594
|
if not self.portfolio.can_be_rebalanced:
|
|
495
|
-
errors["portfolio"] =
|
|
496
|
-
"The portfolio needs to be a model portfolio in order to revert this trade proposal manually"
|
|
497
|
-
|
|
595
|
+
errors["portfolio"] = [
|
|
596
|
+
_("The portfolio needs to be a model portfolio in order to revert this trade proposal manually")
|
|
597
|
+
]
|
|
498
598
|
return errors
|
|
499
599
|
|
|
500
600
|
# End FSM logics
|
|
@@ -20,6 +20,7 @@ from django.db.models.functions import Coalesce
|
|
|
20
20
|
from django.db.models.signals import post_save
|
|
21
21
|
from django.dispatch import receiver
|
|
22
22
|
from django.utils.functional import cached_property
|
|
23
|
+
from django.utils.translation import gettext_lazy as _
|
|
23
24
|
from django_fsm import GET_STATE, FSMField, transition
|
|
24
25
|
from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet
|
|
25
26
|
from wbcore.contrib.icons import WBIcon
|
|
@@ -136,6 +137,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
136
137
|
REDEMPTION = "REDEMPTION", "Redemption"
|
|
137
138
|
BUY = "BUY", "Buy"
|
|
138
139
|
SELL = "SELL", "Sell"
|
|
140
|
+
NO_CHANGE = "NO_CHANGE", "No Change" # default transaction subtype if weighing is 0
|
|
139
141
|
|
|
140
142
|
external_identifier2 = models.CharField(
|
|
141
143
|
max_length=255,
|
|
@@ -224,7 +226,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
224
226
|
source=Status.DRAFT,
|
|
225
227
|
target=GET_STATE(
|
|
226
228
|
lambda self, **kwargs: (
|
|
227
|
-
self.Status.SUBMIT if self.
|
|
229
|
+
self.Status.SUBMIT if self.last_underlying_quote_price is not None else self.Status.FAILED
|
|
228
230
|
),
|
|
229
231
|
states=[Status.SUBMIT, Status.FAILED],
|
|
230
232
|
),
|
|
@@ -245,7 +247,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
245
247
|
on_error="FAILED",
|
|
246
248
|
)
|
|
247
249
|
def submit(self, by=None, description=None, **kwargs):
|
|
248
|
-
if not self.
|
|
250
|
+
if not self.last_underlying_quote_price:
|
|
249
251
|
self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
250
252
|
|
|
251
253
|
def can_submit(self):
|
|
@@ -263,13 +265,15 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
263
265
|
self.comment = f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
264
266
|
|
|
265
267
|
@cached_property
|
|
266
|
-
def
|
|
268
|
+
def last_underlying_quote_price(self) -> InstrumentPrice | None:
|
|
267
269
|
try:
|
|
270
|
+
# we try t0 first
|
|
268
271
|
return InstrumentPrice.objects.filter_only_valid_prices().get(
|
|
269
|
-
instrument=self.underlying_instrument, date=self.
|
|
272
|
+
instrument=self.underlying_instrument, date=self.transaction_date
|
|
270
273
|
)
|
|
271
274
|
except InstrumentPrice.DoesNotExist:
|
|
272
275
|
with suppress(InstrumentPrice.DoesNotExist):
|
|
276
|
+
# we fall back to the latest price before t0
|
|
273
277
|
return (
|
|
274
278
|
InstrumentPrice.objects.filter_only_valid_prices()
|
|
275
279
|
.filter(instrument=self.underlying_instrument, date__lte=self.value_date)
|
|
@@ -296,7 +300,7 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
296
300
|
},
|
|
297
301
|
)
|
|
298
302
|
def execute(self, **kwargs):
|
|
299
|
-
if self.
|
|
303
|
+
if self.last_underlying_quote_price:
|
|
300
304
|
asset, created = AssetPosition.unannotated_objects.update_or_create(
|
|
301
305
|
underlying_quote=self.underlying_instrument,
|
|
302
306
|
portfolio_created=None,
|
|
@@ -305,9 +309,9 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
305
309
|
defaults={
|
|
306
310
|
"initial_currency_fx_rate": self.currency_fx_rate,
|
|
307
311
|
"weighting": self._target_weight,
|
|
308
|
-
"initial_price": self.
|
|
312
|
+
"initial_price": self.last_underlying_quote_price.net_value,
|
|
309
313
|
"initial_shares": None,
|
|
310
|
-
"underlying_quote_price": self.
|
|
314
|
+
"underlying_quote_price": self.last_underlying_quote_price,
|
|
311
315
|
"asset_valuation_date": self.transaction_date,
|
|
312
316
|
"currency": self.currency,
|
|
313
317
|
"is_estimated": False,
|
|
@@ -316,10 +320,12 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
316
320
|
asset.set_weighting(self._target_weight)
|
|
317
321
|
|
|
318
322
|
def can_execute(self):
|
|
319
|
-
if not self.
|
|
320
|
-
return {"underlying_instrument": "Cannot execute a trade without a valid quote price"}
|
|
323
|
+
if not self.last_underlying_quote_price:
|
|
324
|
+
return {"underlying_instrument": [_("Cannot execute a trade without a valid quote price")]}
|
|
321
325
|
if not self.portfolio.is_manageable:
|
|
322
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
"portfolio": [_("The portfolio needs to be a model portfolio in order to execute this trade manually")]
|
|
328
|
+
}
|
|
323
329
|
|
|
324
330
|
@transition(
|
|
325
331
|
field=status,
|
|
@@ -494,26 +500,20 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
494
500
|
self.portfolio = self.trade_proposal.portfolio
|
|
495
501
|
self.transaction_date = self.trade_proposal.trade_date
|
|
496
502
|
self.value_date = self.trade_proposal.last_effective_date
|
|
497
|
-
if self.
|
|
498
|
-
|
|
503
|
+
if not self.portfolio.only_weighting:
|
|
504
|
+
with suppress(ValueError):
|
|
505
|
+
self.shares = self.trade_proposal.get_estimated_shares(self.weighting, self.underlying_instrument)
|
|
499
506
|
|
|
500
507
|
if not self.custodian and self.bank:
|
|
501
508
|
self.custodian = Custodian.get_by_mapping(self.bank)
|
|
502
509
|
if self.price is None:
|
|
503
510
|
# we try to get the price if not provided directly from the underlying instrument
|
|
504
|
-
with suppress(
|
|
505
|
-
self.price = self.underlying_instrument.
|
|
506
|
-
if self.price is not None and self.price_gross is None:
|
|
507
|
-
self.price_gross = self.price
|
|
508
|
-
|
|
509
|
-
if self.price is not None and self.shares is not None and self.total_value is None:
|
|
510
|
-
self.total_value = self.price * self.shares
|
|
511
|
+
with suppress(Exception):
|
|
512
|
+
self.price = self.underlying_instrument.get_price(self.value_date)
|
|
511
513
|
|
|
512
|
-
if self.price_gross is not None and self.shares is not None and self.total_value_gross is None:
|
|
513
|
-
self.total_value_gross = self.price_gross * self.shares
|
|
514
514
|
self.transaction_type = Transaction.Type.TRADE
|
|
515
515
|
|
|
516
|
-
if self.transaction_subtype is None:
|
|
516
|
+
if self.transaction_subtype is None or self.trade_proposal:
|
|
517
517
|
# if subtype not provided, we extract it automatically from the existing data.
|
|
518
518
|
self._set_transaction_subtype()
|
|
519
519
|
if self.id and hasattr(self, "claims"):
|
|
@@ -525,6 +525,8 @@ class Trade(ShareMixin, Transaction, OrderedModel, WBModel):
|
|
|
525
525
|
super().save(*args, **kwargs)
|
|
526
526
|
|
|
527
527
|
def _set_transaction_subtype(self):
|
|
528
|
+
if self.weighting == 0:
|
|
529
|
+
self.transaction_subtype = Trade.Type.NO_CHANGE
|
|
528
530
|
if self.underlying_instrument.instrument_type.key == "product":
|
|
529
531
|
if self.shares is not None:
|
|
530
532
|
if self.shares > 0:
|
|
@@ -13,16 +13,16 @@ class ShareMixin(models.Model):
|
|
|
13
13
|
shares = models.DecimalField(
|
|
14
14
|
max_digits=15,
|
|
15
15
|
decimal_places=4,
|
|
16
|
-
|
|
17
|
-
blank=True,
|
|
16
|
+
default=Decimal("0.0"),
|
|
18
17
|
help_text="The number of shares that were traded.",
|
|
19
18
|
verbose_name="Shares",
|
|
20
19
|
)
|
|
21
20
|
price = models.DecimalField(
|
|
22
21
|
max_digits=16,
|
|
23
22
|
decimal_places=4,
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
default=Decimal(
|
|
24
|
+
"0.0"
|
|
25
|
+
), # we shouldn't default to anything but we have trade with price=None. Needs to be handled carefully
|
|
26
26
|
help_text="The price per share.",
|
|
27
27
|
verbose_name="Price",
|
|
28
28
|
)
|
|
@@ -30,12 +30,23 @@ class ShareMixin(models.Model):
|
|
|
30
30
|
price_gross = models.DecimalField(
|
|
31
31
|
max_digits=16,
|
|
32
32
|
decimal_places=4,
|
|
33
|
-
null=True,
|
|
34
|
-
blank=True,
|
|
35
33
|
help_text="The gross price per share.",
|
|
36
34
|
verbose_name="Gross Price",
|
|
37
35
|
)
|
|
38
36
|
|
|
37
|
+
def save(
|
|
38
|
+
self,
|
|
39
|
+
*args,
|
|
40
|
+
factor: Decimal = Decimal("1.0"),
|
|
41
|
+
**kwargs,
|
|
42
|
+
):
|
|
43
|
+
if self.price_gross is None:
|
|
44
|
+
self.price_gross = self.price
|
|
45
|
+
|
|
46
|
+
self.total_value = self.price * self.shares * factor
|
|
47
|
+
self.total_value_gross = self.price_gross * self.shares * factor
|
|
48
|
+
super().save(*args, **kwargs)
|
|
49
|
+
|
|
39
50
|
class Meta:
|
|
40
51
|
abstract = True
|
|
41
52
|
|
|
@@ -68,14 +79,10 @@ class Transaction(ImportMixin, models.Model):
|
|
|
68
79
|
help_text="The date that this transaction was traded.",
|
|
69
80
|
)
|
|
70
81
|
book_date = models.DateField(
|
|
71
|
-
null=True,
|
|
72
|
-
blank=True,
|
|
73
82
|
verbose_name="Trade Date",
|
|
74
83
|
help_text="The date that this transaction was booked.",
|
|
75
84
|
)
|
|
76
85
|
value_date = models.DateField(
|
|
77
|
-
null=True,
|
|
78
|
-
blank=True,
|
|
79
86
|
verbose_name="Value Date",
|
|
80
87
|
help_text="The date that this transaction was valuated.",
|
|
81
88
|
)
|
|
@@ -89,17 +96,23 @@ class Transaction(ImportMixin, models.Model):
|
|
|
89
96
|
currency_fx_rate = models.DecimalField(
|
|
90
97
|
max_digits=14, decimal_places=8, default=Decimal(1.0), verbose_name="FOREX rate"
|
|
91
98
|
)
|
|
92
|
-
total_value = models.DecimalField(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
total_value = models.DecimalField(max_digits=20, decimal_places=4, verbose_name="Total Value")
|
|
100
|
+
total_value_gross = models.DecimalField(max_digits=20, decimal_places=4, verbose_name="Total Value Gross")
|
|
101
|
+
total_value_fx_portfolio = models.GeneratedField(
|
|
102
|
+
expression=models.F("currency_fx_rate") * models.F("total_value"),
|
|
103
|
+
output_field=models.DecimalField(
|
|
104
|
+
max_digits=20,
|
|
105
|
+
decimal_places=4,
|
|
106
|
+
),
|
|
107
|
+
db_persist=True,
|
|
108
|
+
)
|
|
109
|
+
total_value_gross_fx_portfolio = models.GeneratedField(
|
|
110
|
+
expression=models.F("currency_fx_rate") * models.F("total_value_gross"),
|
|
111
|
+
output_field=models.DecimalField(
|
|
112
|
+
max_digits=20,
|
|
113
|
+
decimal_places=4,
|
|
114
|
+
),
|
|
115
|
+
db_persist=True,
|
|
103
116
|
)
|
|
104
117
|
external_id = models.CharField(
|
|
105
118
|
max_length=255,
|
|
@@ -118,28 +131,15 @@ class Transaction(ImportMixin, models.Model):
|
|
|
118
131
|
|
|
119
132
|
if not getattr(self, "currency", None) and self.underlying_instrument:
|
|
120
133
|
self.currency = self.underlying_instrument.currency
|
|
121
|
-
if
|
|
134
|
+
if self.currency_fx_rate is None:
|
|
122
135
|
self.currency_fx_rate = self.underlying_instrument.currency.convert(
|
|
123
136
|
self.value_date, self.portfolio.currency, exact_lookup=True
|
|
124
137
|
)
|
|
125
138
|
if not self.transaction_type:
|
|
126
139
|
self.transaction_type = self.__class__.__name__
|
|
127
|
-
if (
|
|
128
|
-
self.total_value is not None
|
|
129
|
-
and self.currency_fx_rate is not None
|
|
130
|
-
and self.total_value_fx_portfolio is None
|
|
131
|
-
):
|
|
132
|
-
self.total_value_fx_portfolio = self.total_value * self.currency_fx_rate
|
|
133
|
-
|
|
134
|
-
if self.total_value is not None and self.total_value_gross is None and self.total_value_gross is None:
|
|
135
|
-
self.total_value_gross = self.total_value
|
|
136
140
|
|
|
137
|
-
if
|
|
138
|
-
self.
|
|
139
|
-
and self.total_value_gross is not None
|
|
140
|
-
and self.total_value_gross_fx_portfolio is None
|
|
141
|
-
):
|
|
142
|
-
self.total_value_gross_fx_portfolio = self.total_value_gross * self.currency_fx_rate
|
|
141
|
+
if self.total_value_gross is None:
|
|
142
|
+
self.total_value_gross = self.total_value
|
|
143
143
|
|
|
144
144
|
super().save(*args, **kwargs)
|
|
145
145
|
|
|
@@ -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
|
|