wbportfolio 1.55.9__py2.py3-none-any.whl → 1.56.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 (73) hide show
  1. wbportfolio/api_clients/ubs.py +11 -9
  2. wbportfolio/contrib/company_portfolio/configs/display.py +4 -4
  3. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  4. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  5. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  6. wbportfolio/factories/product_groups.py +3 -3
  7. wbportfolio/factories/products.py +3 -3
  8. wbportfolio/filters/assets.py +0 -1
  9. wbportfolio/filters/positions.py +0 -1
  10. wbportfolio/filters/transactions/fees.py +0 -2
  11. wbportfolio/filters/transactions/trades.py +0 -1
  12. wbportfolio/import_export/handlers/asset_position.py +3 -3
  13. wbportfolio/import_export/handlers/dividend.py +1 -1
  14. wbportfolio/import_export/handlers/fees.py +2 -2
  15. wbportfolio/import_export/handlers/trade.py +4 -4
  16. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  17. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  18. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  19. wbportfolio/import_export/parsers/jpmorgan/strategy.py +2 -2
  20. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  21. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  22. wbportfolio/import_export/parsers/natixis/equity.py +21 -3
  23. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  24. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  25. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  26. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  27. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  28. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  29. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  30. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  31. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  32. wbportfolio/import_export/utils.py +3 -1
  33. wbportfolio/metric/backends/base.py +2 -2
  34. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  35. wbportfolio/models/asset.py +3 -2
  36. wbportfolio/models/builder.py +0 -1
  37. wbportfolio/models/custodians.py +3 -3
  38. wbportfolio/models/exceptions.py +1 -1
  39. wbportfolio/models/graphs/utils.py +11 -11
  40. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  41. wbportfolio/models/orders/order_proposals.py +11 -8
  42. wbportfolio/models/orders/orders.py +84 -62
  43. wbportfolio/models/portfolio.py +7 -7
  44. wbportfolio/models/portfolio_relationship.py +6 -0
  45. wbportfolio/models/products.py +3 -0
  46. wbportfolio/models/rebalancing.py +3 -0
  47. wbportfolio/models/roles.py +4 -10
  48. wbportfolio/models/transactions/claim.py +6 -5
  49. wbportfolio/models/transactions/dividends.py +1 -0
  50. wbportfolio/models/transactions/trades.py +1 -0
  51. wbportfolio/models/transactions/transactions.py +16 -4
  52. wbportfolio/pms/typing.py +1 -1
  53. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  54. wbportfolio/risk_management/backends/controversy_portfolio.py +1 -1
  55. wbportfolio/risk_management/backends/exposure_portfolio.py +2 -2
  56. wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -1
  57. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  58. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  59. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  60. wbportfolio/serializers/orders/orders.py +56 -23
  61. wbportfolio/serializers/transactions/claim.py +2 -2
  62. wbportfolio/tests/models/orders/test_order_proposals.py +27 -7
  63. wbportfolio/tests/models/test_portfolios.py +5 -5
  64. wbportfolio/tests/models/test_splits.py +1 -6
  65. wbportfolio/tests/viewsets/test_products.py +1 -0
  66. wbportfolio/viewsets/charts/assets.py +8 -4
  67. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  68. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  69. wbportfolio/viewsets/orders/orders.py +22 -4
  70. {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/METADATA +1 -1
  71. {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/RECORD +73 -72
  72. {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/WHEEL +0 -0
  73. {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,7 +11,8 @@ from wbportfolio.import_export.utils import get_file_extension
11
11
  def file_name_parse(file_name):
12
12
  isin = re.findall("([A-Z]{2}[A-Z0-9]{9}[0-9]{1})", file_name)
13
13
 
14
- assert len(isin) == 1, "Not exactly 1 isin found in the filename"
14
+ if len(isin) != 1:
15
+ raise ValueError("Not exactly 1 isin found in the filename")
15
16
 
16
17
  return {
17
18
  "isin": isin[0],
@@ -24,7 +24,9 @@ def convert_string_to_number(string):
24
24
  return 0.0
25
25
 
26
26
 
27
- def parse_date(date, formats=[]):
27
+ def parse_date(date, formats: list | None = None):
28
+ if formats is None:
29
+ formats = []
28
30
  if isinstance(date, int) or isinstance(date, float):
29
31
  return xldate_as_datetime(int(date), 0).date()
30
32
  if isinstance(date, str):
@@ -4,7 +4,7 @@ import numpy as np
4
4
  import pandas as pd
5
5
  from django.db.models import QuerySet
6
6
  from wbfdm.contrib.metric.backends.base import AbstractBackend, Metric
7
- from wbfdm.contrib.metric.exceptions import MetricInvalidParameterException
7
+ from wbfdm.contrib.metric.exceptions import MetricInvalidParameterError
8
8
 
9
9
  from wbportfolio.models import AssetPosition, Portfolio
10
10
 
@@ -62,7 +62,7 @@ class PortfolioMetricBaseBackend(AbstractBackend[Portfolio]):
62
62
  try:
63
63
  return qs.latest("date").date
64
64
  except AssetPosition.DoesNotExist:
65
- raise MetricInvalidParameterException()
65
+ raise MetricInvalidParameterError() from None
66
66
 
67
67
  def get_queryset(self) -> QuerySet[Portfolio]:
68
68
  product_portfolios = super().get_queryset().filter_active_and_tracked()
@@ -0,0 +1,44 @@
1
+ # Generated by Django 5.0.12 on 2025-09-18 08:48
2
+
3
+ import django.db.models.expressions
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('wbportfolio', '0089_orderproposal_min_weighting'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='dividendtransaction',
16
+ name='price_fx_portfolio',
17
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
18
+ ),
19
+ migrations.AddField(
20
+ model_name='dividendtransaction',
21
+ name='price_gross_fx_portfolio',
22
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price_gross')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
23
+ ),
24
+ migrations.AddField(
25
+ model_name='order',
26
+ name='price_fx_portfolio',
27
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
28
+ ),
29
+ migrations.AddField(
30
+ model_name='order',
31
+ name='price_gross_fx_portfolio',
32
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price_gross')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
33
+ ),
34
+ migrations.AddField(
35
+ model_name='trade',
36
+ name='price_fx_portfolio',
37
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
38
+ ),
39
+ migrations.AddField(
40
+ model_name='trade',
41
+ name='price_gross_fx_portfolio',
42
+ field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('currency_fx_rate'), '*', models.F('price_gross')), output_field=models.DecimalField(decimal_places=4, max_digits=20)),
43
+ ),
44
+ ]
@@ -5,6 +5,7 @@ from decimal import Decimal, InvalidOperation
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from django.contrib import admin
8
+ from django.core.exceptions import ObjectDoesNotExist
8
9
  from django.db import models
9
10
  from django.db.models import (
10
11
  Case,
@@ -408,7 +409,7 @@ class AssetPosition(ImportMixin, models.Model):
408
409
  analytical_objects = AnalyticalAssetPositionManager()
409
410
  unannotated_objects = models.Manager()
410
411
 
411
- def pre_save(
412
+ def pre_save( # noqa: C901
412
413
  self, create_underlying_quote_price_if_missing: bool = False, infer_underlying_quote_price: bool = True
413
414
  ):
414
415
  if not self.asset_valuation_date:
@@ -429,7 +430,7 @@ class AssetPosition(ImportMixin, models.Model):
429
430
  ):
430
431
  try:
431
432
  self.underlying_quote = self.underlying_instrument.children.get(is_primary=True)
432
- except:
433
+ except ObjectDoesNotExist:
433
434
  self.underlying_quote = self.underlying_instrument
434
435
 
435
436
  if not getattr(self, "currency", None):
@@ -225,7 +225,6 @@ class AssetPositionBuilder:
225
225
 
226
226
  def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
227
227
  positions = list(self.get_positions(**kwargs))
228
-
229
228
  # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
230
229
  # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
231
230
  # change completely the trades of a portfolio model and drift it.
@@ -32,7 +32,7 @@ class Custodian(WBModel):
32
32
 
33
33
  @classmethod
34
34
  def get_by_mapping(cls, mapping: str, use_similarity=False, create_missing=True):
35
- SIMILIRATY_SCORE = 0.7
35
+ similarity_score = 0.7
36
36
  lower_mapping = mapping.lower()
37
37
  try:
38
38
  return cls.objects.get(mapping__contains=[lower_mapping])
@@ -40,7 +40,7 @@ class Custodian(WBModel):
40
40
  if use_similarity:
41
41
  similar_custodians = cls.objects.annotate(
42
42
  similarity_score=TrigramSimilarity("name", lower_mapping)
43
- ).filter(similarity_score__gt=SIMILIRATY_SCORE)
43
+ ).filter(similarity_score__gt=similarity_score)
44
44
  if similar_custodians.count() == 1:
45
45
  custodian = similar_custodians.first()
46
46
  print(f"find similar custodian {lower_mapping} -> {custodian.name}") # noqa: T201
@@ -50,7 +50,7 @@ class Custodian(WBModel):
50
50
  else:
51
51
  similar_companies = Company.objects.annotate(
52
52
  similarity_score=TrigramSimilarity("name", lower_mapping)
53
- ).filter(similarity_score__gt=SIMILIRATY_SCORE)
53
+ ).filter(similarity_score__gt=similarity_score)
54
54
  if similar_companies.count() == 1:
55
55
  print( # noqa: T201
56
56
  f"Find similar company {lower_mapping} -> {similar_companies.first().name}"
@@ -1,2 +1,2 @@
1
- class InvalidAnalyticPortfolio(Exception):
1
+ class InvalidAnalyticPortfolioError(Exception):
2
2
  pass
@@ -3,21 +3,21 @@ import plotly.graph_objects as go
3
3
  from networkx.drawing.nx_agraph import graphviz_layout
4
4
 
5
5
 
6
- def reformat_graph_layout(G, layout):
6
+ def reformat_graph_layout(g, layout):
7
7
  """
8
8
  this method provide positions based on layout algorithm
9
- :param G:
9
+ :param g:
10
10
  :param layout:
11
11
  :return:
12
12
  """
13
13
  if layout == "graphviz":
14
- positions = graphviz_layout(G)
14
+ positions = graphviz_layout(g)
15
15
  elif layout == "spring":
16
- positions = nx.fruchterman_reingold_layout(G, k=0.5, iterations=1000)
16
+ positions = nx.fruchterman_reingold_layout(g, k=0.5, iterations=1000)
17
17
  elif layout == "spectral":
18
- positions = nx.spectral_layout(G, scale=0.1)
18
+ positions = nx.spectral_layout(g, scale=0.1)
19
19
  elif layout == "random":
20
- positions = nx.random_layout(G)
20
+ positions = nx.random_layout(g)
21
21
  else:
22
22
  raise Exception("please specify the layout from graphviz, spring, spectral or random")
23
23
 
@@ -25,7 +25,7 @@ def reformat_graph_layout(G, layout):
25
25
 
26
26
 
27
27
  def networkx_graph_to_plotly(
28
- G: nx.Graph,
28
+ g: nx.Graph,
29
29
  labels: dict[str, str] | None = None,
30
30
  node_size: int = 10,
31
31
  edge_weight: int = 1,
@@ -36,12 +36,12 @@ def networkx_graph_to_plotly(
36
36
  """
37
37
  Visualize a NetworkX graph using Plotly.
38
38
  """
39
- positions = reformat_graph_layout(G, layout)
39
+ positions = reformat_graph_layout(g, layout)
40
40
  if not labels:
41
41
  labels = {}
42
42
  # Initialize edge traces
43
43
  edge_traces = []
44
- for edge in G.edges():
44
+ for edge in g.edges():
45
45
  x0, y0 = positions[edge[0]]
46
46
  x1, y1 = positions[edge[1]]
47
47
 
@@ -52,12 +52,12 @@ def networkx_graph_to_plotly(
52
52
 
53
53
  # Initialize node trace
54
54
  node_x, node_y, node_colors, node_labels = [], [], [], []
55
- for node in G.nodes():
55
+ for node in g.nodes():
56
56
  x, y = positions[node]
57
57
  node_x.append(x)
58
58
  node_y.append(y)
59
59
  node_labels.append(labels.get(node, node))
60
- node_colors.append(len(list(G.neighbors(node)))) # Color based on degree
60
+ node_colors.append(len(list(g.neighbors(node)))) # Color based on degree
61
61
 
62
62
  node_trace = go.Scatter(
63
63
  x=node_x,
@@ -834,7 +834,7 @@ class LiquidityStressMixin:
834
834
 
835
835
  """ The main function for the liquidity stress tests """
836
836
 
837
- def liquidity_stress_test(
837
+ def liquidity_stress_test( # noqa: C901
838
838
  self,
839
839
  report_date: Optional[date] = None,
840
840
  weights_date: Optional[date] = None,
@@ -527,10 +527,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
527
527
  total_target_weight=self.total_expected_target_weight,
528
528
  )
529
529
  leftovers_orders = self.orders.all()
530
+ portfolio_value = self.portfolio_total_asset_value
530
531
  for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
531
532
  with suppress(Order.DoesNotExist):
532
533
  order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
533
- order.weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
534
+ order.set_weighting(round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION), portfolio_value)
534
535
  order.save()
535
536
  leftovers_orders = leftovers_orders.exclude(id=order.id)
536
537
  leftovers_orders.delete()
@@ -539,11 +540,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
539
540
  def fix_quantization(self):
540
541
  if self.orders.exists():
541
542
  orders = self.get_orders()
543
+ portfolio_value = self.portfolio_total_asset_value
542
544
  t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
543
545
  # we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
544
546
  if quantize_error := (t_weight - self.total_expected_target_weight):
545
547
  biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
546
- biggest_order.weighting -= quantize_error
548
+ biggest_order.set_weighting(biggest_order.weighting - quantize_error, portfolio_value)
547
549
  biggest_order.save()
548
550
 
549
551
  def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
@@ -594,6 +596,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
594
596
  orders = service.trades_batch.trades_map.values()
595
597
 
596
598
  objs = []
599
+ portfolio_value = self.portfolio_total_asset_value
597
600
  for order_dto in orders:
598
601
  instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
599
602
  currency_fx_rate = instrument.currency.convert(
@@ -604,7 +607,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
604
607
  daily_return = order_dto.daily_return
605
608
  try:
606
609
  order = self.orders.get(underlying_instrument=instrument)
607
- order.weighting = weighting
608
610
  order.currency_fx_rate = currency_fx_rate
609
611
  order.daily_return = daily_return
610
612
  except Order.DoesNotExist:
@@ -616,11 +618,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
616
618
  daily_return=daily_return,
617
619
  currency_fx_rate=currency_fx_rate,
618
620
  )
621
+ order.pre_save()
622
+ order.set_weighting(weighting, portfolio_value)
619
623
  order.desired_target_weight = order_dto.target_weight
620
624
  order.order_type = Order.get_type(
621
625
  weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
622
626
  )
623
- order.pre_save()
624
627
  # # if we cannot automatically find a price, we consider the stock is invalid and we sell it
625
628
  # if not order.price and order.weighting > 0:
626
629
  # order.price = Decimal("0.0")
@@ -638,7 +641,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
638
641
  "price",
639
642
  "price_gross",
640
643
  "desired_target_weight",
641
- # "shares"
644
+ "shares",
642
645
  ],
643
646
  unique_fields=["order_proposal", "underlying_instrument"],
644
647
  update_conflicts=True,
@@ -675,7 +678,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
675
678
  except (ValidationError, DatabaseError) as e:
676
679
  self.status = OrderProposal.Status.FAILED
677
680
  if not silent_exception:
678
- raise ValidationError(e)
681
+ raise ValidationError(e) from e
679
682
  return
680
683
  logger.info("Submitting order proposal ...")
681
684
  self.submit()
@@ -790,7 +793,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
790
793
  price_fx_portfolio = quote_price * underlying_quote.currency.convert(
791
794
  self.trade_date, self.portfolio.currency, exact_lookup=False
792
795
  )
793
-
794
796
  # If the price is valid, calculate and return the estimated shares
795
797
  if price_fx_portfolio:
796
798
  return trade_total_value_fx_portfolio / price_fx_portfolio
@@ -888,6 +890,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
888
890
  order_warnings = order.submit(
889
891
  by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
890
892
  )
893
+
891
894
  if order_warnings:
892
895
  orders_validation_warnings.extend(order_warnings)
893
896
  orders.append(order)
@@ -1193,7 +1196,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1193
1196
  return warning
1194
1197
 
1195
1198
  def can_cancelexecution(self):
1196
- if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1199
+ if self.execution_status not in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1197
1200
  return {"execution_status": "Execution can only be cancelled if it is not already executed"}
1198
1201
 
1199
1202
  def update_execution_status(self):
@@ -71,44 +71,25 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
71
71
  execution_confirmed = models.BooleanField(default=False, verbose_name="Execution Confirmed")
72
72
  execution_comment = models.TextField(default="", blank=True, verbose_name="Execution Comment")
73
73
 
74
- def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
75
- warnings = []
74
+ order_with_respect_to = "order_proposal"
76
75
 
77
- # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
78
- if self.order_proposal and not self.portfolio.only_weighting:
79
- shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
80
- if shares != self.shares:
81
- warnings.append(
82
- f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
83
- )
84
- shares = round(shares) # ensure fractional shares are converted into integer
85
- # we need to recompute the delta weight has we changed the number of shares
86
- if shares != self.shares:
87
- self.shares = shares
88
- if portfolio_total_asset_value:
89
- self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
90
- if abs(self.weighting) < self.order_proposal.min_weighting:
91
- warnings.append(
92
- f"Weighting for order {self.underlying_instrument.computed_str} ({self.weighting}) is bellow the allowed Minimum Weighting ({self.order_proposal.min_weighting})"
93
- )
94
- self.shares = Decimal("0")
95
- self.weighting = Decimal("0")
96
- if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
97
- warnings.append(
98
- f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
99
- )
100
- self.shares = Decimal("0")
101
- self.weighting = Decimal("0")
102
- if not self.price:
103
- warnings.append(f"No price for {self.underlying_instrument.computed_str}")
104
- if (
105
- not self.underlying_instrument.is_cash
106
- and not self.underlying_instrument.is_cash_equivalent
107
- and self._target_weight < -1e-8
108
- ): # any value below -1e8 will be considered zero
109
- warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
110
- self.desired_target_weight = self._target_weight
111
- return warnings
76
+ class Meta(OrderedModel.Meta):
77
+ verbose_name = "Order"
78
+ verbose_name_plural = "Orders"
79
+ indexes = [
80
+ models.Index(fields=["order_proposal"]),
81
+ models.Index(fields=["underlying_instrument", "value_date"]),
82
+ models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
83
+ models.Index(fields=["order_proposal", "underlying_instrument"]),
84
+ # models.Index(fields=["date", "underlying_instrument"]),
85
+ ]
86
+ constraints = [
87
+ models.UniqueConstraint(
88
+ fields=["order_proposal", "underlying_instrument"],
89
+ name="unique_order",
90
+ ),
91
+ ]
92
+ # notification_email_template = "portfolio/email/trade_notification.html"
112
93
 
113
94
  @property
114
95
  def product(self):
@@ -176,25 +157,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
176
157
  def _target_shares(self) -> Decimal:
177
158
  return getattr(self, "target_shares", self._effective_shares + self.shares)
178
159
 
179
- order_with_respect_to = "order_proposal"
180
-
181
- class Meta(OrderedModel.Meta):
182
- verbose_name = "Order"
183
- verbose_name_plural = "Orders"
184
- indexes = [
185
- models.Index(fields=["order_proposal"]),
186
- models.Index(fields=["underlying_instrument", "value_date"]),
187
- models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
188
- models.Index(fields=["order_proposal", "underlying_instrument"]),
189
- # models.Index(fields=["date", "underlying_instrument"]),
190
- ]
191
- constraints = [
192
- models.UniqueConstraint(
193
- fields=["order_proposal", "underlying_instrument"],
194
- name="unique_order",
195
- ),
196
- ]
197
- # notification_email_template = "portfolio/email/trade_notification.html"
160
+ def __str__(self):
161
+ ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
162
+ return f"{ticker}{self.weighting}"
198
163
 
199
164
  def pre_save(self):
200
165
  self.portfolio = self.order_proposal.portfolio
@@ -240,15 +205,72 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
240
205
  else:
241
206
  return Order.Type.SELL
242
207
 
243
- def set_type(self):
244
- self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
245
-
246
208
  def get_price(self) -> Decimal:
247
209
  try:
248
210
  return self.underlying_instrument.get_price(self.value_date)
249
211
  except ValueError:
250
212
  return Decimal("0")
251
213
 
252
- def __str__(self):
253
- ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
254
- return f"{ticker}{self.weighting}"
214
+ def set_type(self):
215
+ self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
216
+
217
+ def set_weighting(self, weighting: Decimal, portfolio_value: Decimal):
218
+ self.weighting = weighting
219
+ price_fx_portfolio = self.price * self.currency_fx_rate
220
+ if price_fx_portfolio and portfolio_value:
221
+ total_value = self.weighting * portfolio_value
222
+ self.shares = total_value / price_fx_portfolio
223
+ else:
224
+ self.shares = Decimal("0")
225
+
226
+ def set_shares(self, shares: Decimal, portfolio_value: Decimal):
227
+ if portfolio_value:
228
+ price_fx_portfolio = self.price * self.currency_fx_rate
229
+ self.shares = shares
230
+ total_value = shares * price_fx_portfolio
231
+ self.weighting = total_value / portfolio_value
232
+ else:
233
+ self.weighting = self.shares = Decimal("0")
234
+
235
+ def set_total_value_fx_portfolio(self, total_value_fx_portfolio: Decimal, portfolio_value: Decimal):
236
+ price_fx_portfolio = self.price * self.currency_fx_rate
237
+ if price_fx_portfolio and portfolio_value:
238
+ self.shares = total_value_fx_portfolio / price_fx_portfolio
239
+ self.weighting = total_value_fx_portfolio / portfolio_value
240
+ else:
241
+ self.weighting = self.shares = Decimal("0")
242
+
243
+ def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
244
+ warnings = []
245
+
246
+ # if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
247
+ if self.order_proposal and not self.portfolio.only_weighting:
248
+ shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
249
+ if shares != self.shares:
250
+ warnings.append(
251
+ f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
252
+ )
253
+ shares = round(shares) # ensure fractional shares are converted into integer
254
+ # we need to recompute the delta weight has we changed the number of shares
255
+ if shares != self.shares:
256
+ self.set_shares(shares, portfolio_total_asset_value)
257
+ if abs(self.weighting) < self.order_proposal.min_weighting:
258
+ warnings.append(
259
+ f"Weighting for order {self.underlying_instrument.computed_str} ({self.weighting}) is bellow the allowed Minimum Weighting ({self.order_proposal.min_weighting})"
260
+ )
261
+ self.set_weighting(Decimal("0"), portfolio_total_asset_value)
262
+ if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
263
+ warnings.append(
264
+ f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
265
+ )
266
+ self.set_weighting(Decimal("0"), portfolio_total_asset_value)
267
+ if not self.price:
268
+ warnings.append(f"No price for {self.underlying_instrument.computed_str}")
269
+ if (
270
+ not self.underlying_instrument.is_cash
271
+ and not self.underlying_instrument.is_cash_equivalent
272
+ and self._target_weight < -1e-8
273
+ ): # any value below -1e8 will be considered zero
274
+ warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
275
+ self.desired_target_weight = self._target_weight
276
+ return warnings
@@ -72,7 +72,7 @@ class DefaultPortfolioQueryset(QuerySet):
72
72
  """
73
73
  A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
74
74
  """
75
- MAX_ITERATIONS: int = (
75
+ max_iterations: int = (
76
76
  5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
77
77
  )
78
78
  remaining_portfolios = set(self)
@@ -85,7 +85,7 @@ class DefaultPortfolioQueryset(QuerySet):
85
85
  dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
86
86
  portfolio=p, dependency_portfolio__in=remaining_portfolios
87
87
  ) # get dependency portfolios
88
- if iterator_counter >= MAX_ITERATIONS or (
88
+ if iterator_counter >= max_iterations or (
89
89
  not dependency_relationships.exists() and not bool(parent_portfolios)
90
90
  ): # if not dependency portfolio or parent portfolio that remained, then we yield
91
91
  remaining_portfolios.remove(p)
@@ -949,7 +949,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
949
949
  except IndexError:
950
950
  position.portfolio_created = None
951
951
 
952
- setattr(position, "path", path)
952
+ position.path = path
953
953
  position.initial_shares = None
954
954
  if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
955
955
  position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
@@ -982,13 +982,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
982
982
  )
983
983
  if not to_date:
984
984
  to_date = from_date
985
- for from_date in pd.date_range(from_date, to_date, freq="B").date:
986
- logger.info(f"Compute Look-Through for {self} at {from_date}")
985
+ for val_date in pd.date_range(from_date, to_date, freq="B").date:
986
+ logger.info(f"Compute Look-Through for {self} at {val_date}")
987
987
  portfolio_total_asset_value = (
988
- self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
988
+ self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
989
989
  )
990
990
  self.builder.add(
991
- list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
991
+ list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
992
992
  infer_underlying_quote_price=True,
993
993
  )
994
994
  self.builder.bulk_create_positions(delete_leftovers=True)
@@ -61,6 +61,9 @@ class InstrumentPortfolioThroughModel(models.Model):
61
61
  models.UniqueConstraint(fields=["instrument", "portfolio"], name="unique_portfolio_relationship"),
62
62
  ]
63
63
 
64
+ def __str__(self) -> str:
65
+ return f"{self.instrument} - {self.portfolio}"
66
+
64
67
  @classmethod
65
68
  def get_portfolio(cls, instrument):
66
69
  with suppress(InstrumentPortfolioThroughModel.DoesNotExist):
@@ -99,6 +102,9 @@ class PortfolioInstrumentPreferredClassificationThroughModel(models.Model):
99
102
  related_name="preferred_classification_group_throughs",
100
103
  )
101
104
 
105
+ def __str__(self) -> str:
106
+ return f"{self.portfolio} - {self.instrument}: ({self.classification})"
107
+
102
108
  def save(self, *args, **kwargs) -> None:
103
109
  if not self.classification_group and self.classification:
104
110
  self.classification_group = self.classification.group
@@ -190,6 +190,9 @@ class FeeProductPercentage(models.Model):
190
190
  ),
191
191
  ]
192
192
 
193
+ def __str__(self) -> str:
194
+ return f"{self.product.name} ({self.type})"
195
+
193
196
  @property
194
197
  def net_percent(self) -> Decimal:
195
198
  return self.percent
@@ -89,6 +89,9 @@ class Rebalancer(ComplexToStringMixin, models.Model):
89
89
  help_text=_("The Evaluation Frequency in RRULE format"),
90
90
  )
91
91
 
92
+ def __str__(self) -> str:
93
+ return f"{self.portfolio.name} ({self.rebalancing_model})"
94
+
92
95
  def save(self, *args, **kwargs):
93
96
  if not self.activation_date:
94
97
  try:
@@ -52,16 +52,10 @@ class PortfolioRole(models.Model):
52
52
  return f"{self.role_type} {self.person.computed_str}"
53
53
 
54
54
  def save(self, *args, **kwargs):
55
- assert (
56
- self.role_type in [self.RoleType.MANAGER, self.RoleType.RISK_MANAGER] and not self.instrument
57
- ) or self.role_type in [
58
- self.RoleType.PORTFOLIO_MANAGER,
59
- self.RoleType.ANALYST,
60
- ], self.default_error_messages["manager"].format(model="instrument")
61
-
62
- assert (self.start and self.end and self.start < self.end) or (
63
- not self.start or not self.end
64
- ), self.default_error_messages["start_end"]
55
+ if self.role_type in [self.RoleType.MANAGER, self.RoleType.RISK_MANAGER] and self.instrument:
56
+ raise ValueError(self.default_error_messages["manager"].format(model="instrument"))
57
+ if self.start and self.end and self.start > self.end:
58
+ raise ValueError(self.default_error_messages["start_end"])
65
59
 
66
60
  super().save(*args, **kwargs)
67
61
 
@@ -259,9 +259,10 @@ class Claim(ReferenceIDMixin, WBModel):
259
259
  return f"{self.reference_id} {self.product.name} ({self.bank} - {self.shares:,} shares - {self.date}) "
260
260
 
261
261
  def save(self, *args, auto_match: bool = True, **kwargs):
262
- assert (
263
- self.shares is not None or self.nominal_amount is not None
264
- ), f"Either shares or nominal amount have to be provided. Shares={self.shares}, Nominal={self.nominal_amount}"
262
+ if self.shares is None and self.nominal_amount is None:
263
+ raise ValueError(
264
+ f"Either shares or nominal amount have to be provided. Shares={self.shares}, Nominal={self.nominal_amount}"
265
+ )
265
266
  if self.product:
266
267
  if self.shares is not None:
267
268
  self.nominal_amount = self.shares * self.product.share_price
@@ -447,7 +448,7 @@ class Claim(ReferenceIDMixin, WBModel):
447
448
  return self.can_approve()
448
449
 
449
450
  def auto_match(self) -> Trade | None:
450
- SHARES_EPSILON = 1 # share
451
+ shares_epsilon = 1 # share
451
452
  auto_match_trade = None
452
453
  # Obvious filtering
453
454
  trades = Trade.valid_customer_trade_objects.filter(
@@ -458,7 +459,7 @@ class Claim(ReferenceIDMixin, WBModel):
458
459
  trades = trades.filter(underlying_instrument=self.product)
459
460
  # Find trades by shares (or remaining to be claimed)
460
461
  trades = trades.filter(
461
- Q(diff_shares__lte=self.shares + SHARES_EPSILON) & Q(diff_shares__gte=self.shares - SHARES_EPSILON)
462
+ Q(diff_shares__lte=self.shares + shares_epsilon) & Q(diff_shares__gte=self.shares - shares_epsilon)
462
463
  )
463
464
  if trades.count() == 1:
464
465
  auto_match_trade = trades.first()
@@ -41,6 +41,7 @@ class DividendTransaction(TransactionMixin, ImportMixin, models.Model):
41
41
  )
42
42
 
43
43
  def save(self, *args, **kwargs):
44
+ self.pre_save()
44
45
  if not self.record_date and self.ex_date:
45
46
  self.record_date = self.ex_date
46
47
  elif self.record_date and not self.ex_date: