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.

Files changed (46) hide show
  1. wbportfolio/contrib/company_portfolio/tasks.py +8 -4
  2. wbportfolio/dynamic_preferences_registry.py +9 -0
  3. wbportfolio/factories/__init__.py +1 -3
  4. wbportfolio/factories/portfolios.py +0 -12
  5. wbportfolio/factories/product_groups.py +8 -1
  6. wbportfolio/factories/products.py +18 -0
  7. wbportfolio/factories/trades.py +5 -1
  8. wbportfolio/import_export/handlers/trade.py +8 -0
  9. wbportfolio/import_export/resources/trades.py +19 -30
  10. wbportfolio/metric/backends/base.py +3 -15
  11. wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
  12. wbportfolio/models/__init__.py +1 -2
  13. wbportfolio/models/asset.py +1 -1
  14. wbportfolio/models/portfolio.py +20 -13
  15. wbportfolio/models/products.py +50 -1
  16. wbportfolio/models/transactions/rebalancing.py +7 -1
  17. wbportfolio/models/transactions/trade_proposals.py +172 -67
  18. wbportfolio/models/transactions/trades.py +34 -25
  19. wbportfolio/pms/trading/handler.py +1 -1
  20. wbportfolio/pms/typing.py +3 -0
  21. wbportfolio/preferences.py +6 -1
  22. wbportfolio/rebalancing/models/composite.py +14 -1
  23. wbportfolio/risk_management/backends/accounts.py +14 -6
  24. wbportfolio/risk_management/backends/exposure_portfolio.py +36 -5
  25. wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
  26. wbportfolio/serializers/portfolios.py +26 -0
  27. wbportfolio/serializers/transactions/trade_proposals.py +2 -13
  28. wbportfolio/serializers/transactions/trades.py +13 -0
  29. wbportfolio/tasks.py +4 -1
  30. wbportfolio/tests/conftest.py +1 -1
  31. wbportfolio/tests/models/test_portfolios.py +1 -1
  32. wbportfolio/tests/models/test_products.py +26 -0
  33. wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
  34. wbportfolio/tests/models/transactions/test_trades.py +14 -0
  35. wbportfolio/tests/signals.py +1 -1
  36. wbportfolio/tests/viewsets/test_performances.py +2 -1
  37. wbportfolio/viewsets/configs/display/portfolios.py +58 -14
  38. wbportfolio/viewsets/configs/display/trades.py +23 -8
  39. wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
  40. wbportfolio/viewsets/portfolios.py +22 -7
  41. wbportfolio/viewsets/transactions/trade_proposals.py +21 -2
  42. wbportfolio/viewsets/transactions/trades.py +86 -12
  43. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
  44. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +46 -44
  45. {wbportfolio-1.47.1.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
  46. {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 wbfdm.models.instruments.instruments import Instrument
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
- def _get_checked_object_field_name(self) -> str:
86
- """
87
- Mandatory function from the Riskcheck mixin that returns the field (aka portfolio), representing the object to check the rules against.
88
- """
89
- return "portfolio"
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.trade_date),
99
- trades_batch=self._build_dto(),
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 clone(self, **kwargs) -> SelfTradeProposal:
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 _get_target_portfolio(self, **kwargs) -> PortfolioDTO:
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._get_target_portfolio()
230
- # if not effective_portfolio:
231
- # effective_portfolio = target_portfolio
232
- service = TradingService(
233
- self.trade_date,
234
- effective_portfolio=effective_portfolio,
235
- target_portfolio=target_portfolio,
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
- trade.shares = self.estimate_shares(trade)
257
- trade.save()
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 not last_trade_proposal.portfolio.assets.filter(date=last_trade_proposal.trade_date).exists():
267
- if last_trade_proposal.status == TradeProposal.Status.APPROVED:
268
- logger.info("Reverting trade proposal ...")
269
- last_trade_proposal.revert()
270
- if last_trade_proposal.status == TradeProposal.Status.DRAFT:
271
- logger.info("Submitting trade proposal ...")
272
- last_trade_proposal.submit()
273
- if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
274
- logger.info("Approving trade proposal ...")
275
- last_trade_proposal.approve()
276
- last_trade_proposal.save()
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 estimate_shares(self, trade: Trade) -> Decimal | None:
287
- if not self.portfolio.only_weighting and (quote := trade.underlying_quote_price):
288
- trade_total_value_fx_portfolio = (
289
- self.portfolio.get_total_asset_value(trade.value_date) * trade._target_weight
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
- price_fx_portfolio = quote.net_value * trade.currency_fx_rate
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.has_no_rule_or_all_checked_succeed and self.portfolio.is_manageable
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, synchronous=False, **kwargs):
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.has_assigned_active_rules and not self.has_all_check_completed_and_succeed:
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, # we wait for all checks to succeed before proposing the back to draft transition
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.underlying_quote_price is not None else self.Status.FAILED
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.underlying_quote_price:
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 underlying_quote_price(self) -> InstrumentPrice | None:
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.value_date
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.underlying_quote_price:
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.underlying_quote_price.net_value,
311
+ "initial_price": self.last_underlying_quote_price.net_value,
309
312
  "initial_shares": None,
310
- "underlying_quote_price": self.underlying_quote_price,
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.underlying_quote_price:
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._effective_shares:
497
- self.shares = self._effective_shares * self.weighting
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
- if self.underlying_instrument.instrument_type.key == "product":
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
 
@@ -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 latest_trade_proposal.base_assets
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
- report_details["AUM Change"] = (
154
- f'<span style="color:{color}">{start_df.loc[breached_obj_id]:.0f} $ → {end_df.loc[breached_obj_id]:.0f} $</span>'
155
- )
158
+ key = "AUM Change"
159
+ number_prefix = "$"
156
160
  else:
157
- report_details["Shares Change"] = (
158
- f"<span style='color:{color}'>{start_df.loc[breached_obj_id]:.0f} → {end_df.loc[breached_obj_id]:.0f}</span>"
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,