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.

Files changed (46) hide show
  1. wbportfolio/factories/__init__.py +1 -3
  2. wbportfolio/factories/dividends.py +1 -0
  3. wbportfolio/factories/portfolios.py +0 -12
  4. wbportfolio/factories/product_groups.py +8 -1
  5. wbportfolio/factories/products.py +18 -0
  6. wbportfolio/factories/trades.py +5 -1
  7. wbportfolio/import_export/handlers/dividend.py +1 -1
  8. wbportfolio/import_export/handlers/fees.py +1 -1
  9. wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -1
  10. wbportfolio/import_export/handlers/trade.py +13 -2
  11. wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -1
  12. wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -1
  13. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  14. wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py +126 -0
  15. wbportfolio/models/portfolio.py +15 -16
  16. wbportfolio/models/transactions/claim.py +8 -7
  17. wbportfolio/models/transactions/dividends.py +3 -20
  18. wbportfolio/models/transactions/rebalancing.py +7 -1
  19. wbportfolio/models/transactions/trade_proposals.py +163 -63
  20. wbportfolio/models/transactions/trades.py +24 -22
  21. wbportfolio/models/transactions/transactions.py +37 -37
  22. wbportfolio/pms/trading/handler.py +1 -1
  23. wbportfolio/pms/typing.py +3 -0
  24. wbportfolio/rebalancing/models/composite.py +14 -1
  25. wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
  26. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  27. wbportfolio/serializers/portfolios.py +26 -0
  28. wbportfolio/serializers/transactions/trades.py +13 -0
  29. wbportfolio/tests/models/test_portfolios.py +2 -2
  30. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  31. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  32. wbportfolio/tests/signals.py +1 -1
  33. wbportfolio/tests/viewsets/test_performances.py +2 -1
  34. wbportfolio/viewsets/configs/display/assets.py +0 -11
  35. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  36. wbportfolio/viewsets/configs/display/products.py +0 -13
  37. wbportfolio/viewsets/configs/display/trades.py +24 -17
  38. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  39. wbportfolio/viewsets/configs/endpoints/transactions.py +6 -0
  40. wbportfolio/viewsets/portfolios.py +39 -8
  41. wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
  42. wbportfolio/viewsets/transactions/trades.py +105 -13
  43. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/METADATA +1 -1
  44. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/RECORD +46 -43
  45. {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.1.dist-info}/WHEEL +0 -0
  46. {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.trade_date),
100
- trades_batch=self._build_dto(),
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 _get_target_portfolio(self, **kwargs) -> PortfolioDTO:
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._get_target_portfolio()
236
- # if not effective_portfolio:
237
- # effective_portfolio = target_portfolio
238
- service = TradingService(
239
- self.trade_date,
240
- effective_portfolio=effective_portfolio,
241
- target_portfolio=target_portfolio,
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
- trade.shares = self.estimate_shares(trade)
263
- trade.save()
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
- self.reset_trades()
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 estimate_shares(self, trade: Trade) -> Decimal | None:
295
- if not self.portfolio.only_weighting and (quote := trade.underlying_quote_price):
296
- trade_total_value_fx_portfolio = (
297
- self.portfolio.get_total_asset_value(trade.value_date) * trade._target_weight
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
- price_fx_portfolio = quote.net_value * trade.currency_fx_rate
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.has_no_rule_or_all_checked_succeed and self.portfolio.is_manageable
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, synchronous=False, **kwargs):
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"] = "At least one trade needs to be submitted to be able to approve this proposal"
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.has_assigned_active_rules and not self.has_all_check_completed_and_succeed:
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"] = "At least one trade needs to be submitted to be able to deny this proposal"
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, # we wait for all checks to succeed before proposing the back to draft transition
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.underlying_quote_price is not None else self.Status.FAILED
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.underlying_quote_price:
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 underlying_quote_price(self) -> InstrumentPrice | None:
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.value_date
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.underlying_quote_price:
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.underlying_quote_price.net_value,
312
+ "initial_price": self.last_underlying_quote_price.net_value,
309
313
  "initial_shares": None,
310
- "underlying_quote_price": self.underlying_quote_price,
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.underlying_quote_price:
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 {"portfolio": "The portfolio needs to be a model portfolio in order to execute this trade manually"}
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._effective_shares:
498
- self.shares = self._effective_shares * self.weighting
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(InstrumentPrice.DoesNotExist):
505
- self.price = self.underlying_instrument.valuations.get(date=self.value_date).net_value
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
- null=True,
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
- null=True,
25
- blank=True,
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
- max_digits=20, decimal_places=4, null=True, blank=True, verbose_name="Total Value"
94
- )
95
- total_value_fx_portfolio = models.DecimalField(
96
- max_digits=20, decimal_places=4, null=True, blank=True, verbose_name="Total Value Fx Portfolio"
97
- )
98
- total_value_gross = models.DecimalField(
99
- max_digits=20, decimal_places=4, null=True, blank=True, verbose_name="Total Value Gross"
100
- )
101
- total_value_gross_fx_portfolio = models.DecimalField(
102
- max_digits=20, decimal_places=4, null=True, blank=True, verbose_name="Total Value Gross Fx Portfolio"
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 not self.currency_fx_rate:
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.currency_fx_rate is not None
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