wbportfolio 1.55.8__py2.py3-none-any.whl → 1.55.10rc0__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.
Files changed (65) hide show
  1. wbportfolio/admin/portfolio.py +11 -5
  2. wbportfolio/api_clients/ubs.py +11 -9
  3. wbportfolio/contrib/company_portfolio/configs/display.py +4 -4
  4. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  5. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  6. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  7. wbportfolio/factories/product_groups.py +3 -3
  8. wbportfolio/factories/products.py +3 -3
  9. wbportfolio/import_export/handlers/asset_position.py +3 -3
  10. wbportfolio/import_export/handlers/dividend.py +1 -1
  11. wbportfolio/import_export/handlers/fees.py +2 -2
  12. wbportfolio/import_export/handlers/trade.py +3 -3
  13. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  14. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  15. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  16. wbportfolio/import_export/parsers/jpmorgan/strategy.py +2 -2
  17. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  18. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  19. wbportfolio/import_export/parsers/natixis/utils.py +6 -2
  20. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  21. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  22. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  23. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  24. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  25. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  26. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  27. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  28. wbportfolio/import_export/utils.py +3 -1
  29. wbportfolio/metric/backends/base.py +2 -2
  30. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  31. wbportfolio/models/asset.py +2 -1
  32. wbportfolio/models/custodians.py +3 -3
  33. wbportfolio/models/exceptions.py +1 -1
  34. wbportfolio/models/graphs/utils.py +11 -11
  35. wbportfolio/models/orders/order_proposals.py +20 -2
  36. wbportfolio/models/orders/orders.py +6 -0
  37. wbportfolio/models/portfolio.py +27 -7
  38. wbportfolio/models/portfolio_relationship.py +6 -0
  39. wbportfolio/models/products.py +3 -0
  40. wbportfolio/models/rebalancing.py +3 -0
  41. wbportfolio/models/roles.py +4 -10
  42. wbportfolio/models/transactions/claim.py +6 -5
  43. wbportfolio/pms/typing.py +1 -1
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  45. wbportfolio/risk_management/backends/controversy_portfolio.py +1 -1
  46. wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -1
  47. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  48. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  49. wbportfolio/serializers/orders/order_proposals.py +1 -0
  50. wbportfolio/serializers/transactions/claim.py +2 -2
  51. wbportfolio/tasks.py +42 -4
  52. wbportfolio/tests/models/orders/test_order_proposals.py +32 -0
  53. wbportfolio/tests/models/test_portfolios.py +5 -5
  54. wbportfolio/tests/models/test_splits.py +1 -6
  55. wbportfolio/tests/viewsets/test_products.py +1 -0
  56. wbportfolio/viewsets/charts/assets.py +6 -4
  57. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  58. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  59. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +5 -5
  60. {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/METADATA +1 -1
  61. {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/RECORD +63 -64
  62. wbportfolio/fdm/__init__.py +0 -0
  63. wbportfolio/fdm/tasks.py +0 -42
  64. {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/WHEEL +0 -0
  65. {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,71 @@
1
+ # Generated by Django 5.0.12 on 2025-09-15 13:45
2
+
3
+ import django.core.validators
4
+ from decimal import Decimal
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('wbportfolio', '0088_orderproposal_total_effective_portfolio_contribution'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name='orderproposal',
17
+ name='min_weighting',
18
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'), help_text='The minimum weight allowed for this order proposal ', max_digits=9, verbose_name='Minimum Weight'),
19
+ ),
20
+ migrations.AddField(
21
+ model_name='portfolio',
22
+ name='default_order_proposal_min_order_value',
23
+ field=models.IntegerField(default=0, verbose_name='Default Order Proposal Minimum Order Value'),
24
+ ),
25
+ migrations.AddField(
26
+ model_name='portfolio',
27
+ name='default_order_proposal_min_weighting',
28
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'), max_digits=9,
29
+ verbose_name='Default Order Proposal Minimum Weight'),
30
+ ),
31
+ migrations.AddField(
32
+ model_name='portfolio',
33
+ name='default_order_proposal_total_cash_weight',
34
+ field=models.DecimalField(decimal_places=4, default=Decimal('0'), max_digits=5,
35
+ verbose_name='Default Order Proposal Total Cash Weight'),
36
+ ),
37
+ migrations.AlterField(
38
+ model_name='orderproposal',
39
+ name='min_weighting',
40
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'),
41
+ help_text='The minimum weight allowed for this order proposal ', max_digits=9,
42
+ validators=[django.core.validators.MinValueValidator(Decimal('0')),
43
+ django.core.validators.MaxValueValidator(Decimal('1'))],
44
+ verbose_name='Minimum Weight'),
45
+ ),
46
+ migrations.AlterField(
47
+ model_name='orderproposal',
48
+ name='total_cash_weight',
49
+ field=models.DecimalField(decimal_places=4, default=Decimal('0'),
50
+ help_text='The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.',
51
+ max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0')),
52
+ django.core.validators.MaxValueValidator(Decimal('1'))],
53
+ verbose_name='Total Cash Weight'),
54
+ ),
55
+ migrations.AlterField(
56
+ model_name='portfolio',
57
+ name='default_order_proposal_min_weighting',
58
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'), max_digits=9,
59
+ validators=[django.core.validators.MinValueValidator(Decimal('0')),
60
+ django.core.validators.MaxValueValidator(Decimal('1'))],
61
+ verbose_name='Default Order Proposal Minimum Weight'),
62
+ ),
63
+ migrations.AlterField(
64
+ model_name='portfolio',
65
+ name='default_order_proposal_total_cash_weight',
66
+ field=models.DecimalField(decimal_places=4, default=Decimal('0'), max_digits=5,
67
+ validators=[django.core.validators.MinValueValidator(Decimal('0')),
68
+ django.core.validators.MaxValueValidator(Decimal('1'))],
69
+ verbose_name='Default Order Proposal Total Cash Weight'),
70
+ ),
71
+ ]
@@ -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,
@@ -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):
@@ -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,
@@ -7,6 +7,7 @@ from typing import Any, TypeVar
7
7
 
8
8
  from celery import shared_task
9
9
  from django.core.exceptions import ValidationError
10
+ from django.core.validators import MaxValueValidator, MinValueValidator
10
11
  from django.db import DatabaseError, models
11
12
  from django.db.models import (
12
13
  F,
@@ -96,12 +97,22 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
96
97
  min_order_value = models.IntegerField(
97
98
  default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
98
99
  )
100
+ min_weighting = models.DecimalField(
101
+ max_digits=9,
102
+ decimal_places=Order.ORDER_WEIGHTING_PRECISION,
103
+ default=Decimal(0),
104
+ help_text="The minimum weight allowed for this order proposal ",
105
+ verbose_name="Minimum Weight",
106
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
107
+ )
108
+
99
109
  total_cash_weight = models.DecimalField(
100
110
  default=Decimal("0"),
101
111
  decimal_places=4,
102
112
  max_digits=5,
103
113
  verbose_name="Total Cash Weight",
104
114
  help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
115
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
105
116
  )
106
117
  total_effective_portfolio_contribution = models.DecimalField(
107
118
  default=Decimal("1"),
@@ -136,6 +147,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
136
147
  ]
137
148
 
138
149
  def save(self, *args, **kwargs):
150
+ # if the order proposal is created, we default these fields with the portfolio default value for automatic value assignement
151
+ if not self.id and not self.min_order_value:
152
+ self.min_order_value = self.portfolio.default_order_proposal_min_order_value
153
+ if not self.id and not self.min_weighting:
154
+ self.min_weighting = self.portfolio.default_order_proposal_min_weighting
155
+ if not self.id and not self.total_cash_weight:
156
+ self.total_cash_weight = self.portfolio.default_order_proposal_total_cash_weight
139
157
  # if a order proposal is created before the existing earliest order proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
140
158
  if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
141
159
  # we need to set the inception date as the first order proposal trade date (and thus, the first position date). We expect a NAV at 100 then
@@ -657,7 +675,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
657
675
  except (ValidationError, DatabaseError) as e:
658
676
  self.status = OrderProposal.Status.FAILED
659
677
  if not silent_exception:
660
- raise ValidationError(e)
678
+ raise ValidationError(e) from e
661
679
  return
662
680
  logger.info("Submitting order proposal ...")
663
681
  self.submit()
@@ -1175,7 +1193,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
1175
1193
  return warning
1176
1194
 
1177
1195
  def can_cancelexecution(self):
1178
- if not self.execution_status in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1196
+ if self.execution_status not in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
1179
1197
  return {"execution_status": "Execution can only be cancelled if it is not already executed"}
1180
1198
 
1181
1199
  def update_execution_status(self):
@@ -87,6 +87,12 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
87
87
  self.shares = shares
88
88
  if portfolio_total_asset_value:
89
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")
90
96
  if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
91
97
  warnings.append(
92
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})"
@@ -9,6 +9,7 @@ import pandas as pd
9
9
  from celery import shared_task
10
10
  from django.contrib.postgres.fields import DateRangeField
11
11
  from django.core.exceptions import ObjectDoesNotExist
12
+ from django.core.validators import MaxValueValidator, MinValueValidator
12
13
  from django.db import models
13
14
  from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
14
15
  from django.db.models.signals import post_save
@@ -71,7 +72,7 @@ class DefaultPortfolioQueryset(QuerySet):
71
72
  """
72
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.
73
74
  """
74
- MAX_ITERATIONS: int = (
75
+ max_iterations: int = (
75
76
  5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
76
77
  )
77
78
  remaining_portfolios = set(self)
@@ -84,7 +85,7 @@ class DefaultPortfolioQueryset(QuerySet):
84
85
  dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
85
86
  portfolio=p, dependency_portfolio__in=remaining_portfolios
86
87
  ) # get dependency portfolios
87
- if iterator_counter >= MAX_ITERATIONS or (
88
+ if iterator_counter >= max_iterations or (
88
89
  not dependency_relationships.exists() and not bool(parent_portfolios)
89
90
  ): # if not dependency portfolio or parent portfolio that remained, then we yield
90
91
  remaining_portfolios.remove(p)
@@ -233,6 +234,25 @@ class Portfolio(DeleteToDisableMixin, WBModel):
233
234
  blank=True,
234
235
  )
235
236
 
237
+ # OMS default parameters. Used to seed order proposal default value upon creation
238
+ default_order_proposal_min_order_value = models.IntegerField(
239
+ default=0, verbose_name="Default Order Proposal Minimum Order Value"
240
+ )
241
+ default_order_proposal_min_weighting = models.DecimalField(
242
+ max_digits=9,
243
+ decimal_places=8,
244
+ default=Decimal(0),
245
+ verbose_name="Default Order Proposal Minimum Weight",
246
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
247
+ )
248
+ default_order_proposal_total_cash_weight = models.DecimalField(
249
+ default=Decimal("0"),
250
+ decimal_places=4,
251
+ max_digits=5,
252
+ verbose_name="Default Order Proposal Total Cash Weight",
253
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
254
+ )
255
+
236
256
  objects = DefaultPortfolioManager()
237
257
  tracked_objects = ActiveTrackedPortfolioManager()
238
258
 
@@ -929,7 +949,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
929
949
  except IndexError:
930
950
  position.portfolio_created = None
931
951
 
932
- setattr(position, "path", path)
952
+ position.path = path
933
953
  position.initial_shares = None
934
954
  if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
935
955
  position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
@@ -962,13 +982,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
962
982
  )
963
983
  if not to_date:
964
984
  to_date = from_date
965
- for from_date in pd.date_range(from_date, to_date, freq="B").date:
966
- 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}")
967
987
  portfolio_total_asset_value = (
968
- 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
969
989
  )
970
990
  self.builder.add(
971
- 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)),
972
992
  infer_underlying_quote_price=True,
973
993
  )
974
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()
wbportfolio/pms/typing.py CHANGED
@@ -269,7 +269,7 @@ class TradeBatch:
269
269
 
270
270
  def convert_to_portfolio(self, use_effective: bool = False, *extra_positions):
271
271
  positions = []
272
- for instrument, trade in self.trades_map.items():
272
+ for trade in self.trades_map.values():
273
273
  positions.append(
274
274
  Position(
275
275
  underlying_instrument=trade.underlying_instrument,
@@ -108,11 +108,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
108
108
  return df.any()
109
109
  else:
110
110
  if missing_exchanges.exists():
111
- setattr(
112
- self,
113
- "_validation_errors",
114
- f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
115
- )
111
+ self._validation_errors = f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}"
116
112
  return df.all()
117
113
  return False
118
114
 
@@ -44,7 +44,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
44
44
  return RuleBackendSerializer
45
45
 
46
46
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
47
- for instrument_id, weight in portfolio.positions_map.items():
47
+ for instrument_id in portfolio.positions_map.keys():
48
48
  instrument = Instrument.objects.get(id=instrument_id)
49
49
  if (
50
50
  controversies := Controversy.objects.filter(
@@ -64,7 +64,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
64
64
  return RuleBackendSerializer
65
65
 
66
66
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
67
- for instrument_id, weight in portfolio.positions_map.items():
67
+ for instrument_id in portfolio.positions_map.keys():
68
68
  instrument = Instrument.objects.get(id=instrument_id)
69
69
  relationships = self.instruments_relationship.filter(instrument=instrument, validated=True)
70
70
 
@@ -92,7 +92,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
92
92
  date=weekday, net_value=500, calculated=False, instrument=benchmark
93
93
  )
94
94
  RelatedInstrumentThroughModel.objects.create(instrument=product, related_instrument=benchmark, is_primary=True)
95
- setattr(stop_loss_instrument_backend, "dynamic_benchmark_type", "PRIMARY_BENCHMARK")
95
+ stop_loss_instrument_backend.dynamic_benchmark_type = "PRIMARY_BENCHMARK"
96
96
 
97
97
  res = list(stop_loss_instrument_backend.check_rule())
98
98
  assert len(res) == 0
@@ -105,7 +105,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
105
105
  assert len(res) == 1
106
106
  assert res[0].breached_object.id == product.id
107
107
 
108
- setattr(stop_loss_instrument_backend, "static_benchmark", benchmark)
108
+ stop_loss_instrument_backend.static_benchmark = benchmark
109
109
  res = list(stop_loss_instrument_backend.check_rule())
110
110
  assert len(res) == 1
111
111
  assert res[0].breached_object.id == product.id
@@ -119,7 +119,7 @@ class TestStopLossPortfolioRuleModel(PortfolioTestMixin):
119
119
  res = list(stop_loss_portfolio_backend.check_rule())
120
120
  assert len(res) == 0
121
121
 
122
- setattr(stop_loss_portfolio_backend, "static_benchmark", benchmark)
122
+ stop_loss_portfolio_backend.static_benchmark = benchmark
123
123
  res = list(stop_loss_portfolio_backend.check_rule())
124
124
  assert len(res) == 1
125
125
  assert res[0].breached_object.id == instrument.id
@@ -90,6 +90,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
90
90
  "comment",
91
91
  "status",
92
92
  "min_order_value",
93
+ "min_weighting",
93
94
  "_rebalancing_model",
94
95
  "rebalancing_model",
95
96
  "target_portfolio",
@@ -49,8 +49,8 @@ class ClaimAPIModelSerializer(serializers.ModelSerializer):
49
49
  if "isin" in request.data:
50
50
  try:
51
51
  data["product"] = Product.objects.get(isin=request.data["isin"])
52
- except Product.DoesNotExist:
53
- raise ValidationError({"isin": "A product with this ISIN does not exist."})
52
+ except Product.DoesNotExist as e:
53
+ raise ValidationError({"isin": "A product with this ISIN does not exist."}) from e
54
54
  if trade := data.get("trade", None):
55
55
  if not trade.is_claimable:
56
56
  raise ValidationError({"trade": "Only a claimable trade can be selected"})
wbportfolio/tasks.py CHANGED
@@ -1,12 +1,12 @@
1
1
  from contextlib import suppress
2
2
  from datetime import date, timedelta
3
3
 
4
+ from celery import shared_task
4
5
  from django.db.models import ProtectedError, Q
6
+ from tqdm import tqdm
7
+ from wbfdm.models import Controversy, Instrument
5
8
 
6
- from wbportfolio.models import Portfolio, Trade
7
- from wbportfolio.models.products import Product
8
-
9
- from .fdm.tasks import * # noqa
9
+ from wbportfolio.models import AssetPosition, Portfolio, Product, Trade
10
10
 
11
11
 
12
12
  @shared_task(queue="portfolio")
@@ -49,3 +49,41 @@ def update_preferred_classification_per_instrument_and_portfolio_as_task():
49
49
  # - propagate (or update) t-2 asset positions into t-1
50
50
  # - Synchronize wbportfolio at t-1
51
51
  # - Compute Instrument Price estimate at t-1
52
+
53
+
54
+ @shared_task(queue="portfolio")
55
+ def synchronize_portfolio_controversies():
56
+ active_portfolios = Portfolio.objects.filter_active_and_tracked()
57
+ qs = (
58
+ AssetPosition.objects.filter(portfolio__in=active_portfolios)
59
+ .values("underlying_instrument")
60
+ .distinct("underlying_instrument")
61
+ )
62
+ objs = {}
63
+ securities = Instrument.objects.filter(id__in=qs.values("underlying_instrument"))
64
+ securities_mapping = {security.id: security.get_root() for security in securities}
65
+ for controversy in securities.dl.esg_controversies():
66
+ instrument = securities_mapping[controversy["instrument_id"]]
67
+ obj = Controversy.dict_to_model(controversy, instrument)
68
+ objs[obj.external_id] = obj
69
+
70
+ Controversy.objects.bulk_create(
71
+ objs.values(),
72
+ update_fields=[
73
+ "instrument",
74
+ "headline",
75
+ "description",
76
+ "source",
77
+ "direct_involvement",
78
+ "company_response",
79
+ "review",
80
+ "initiated",
81
+ "flag",
82
+ "status",
83
+ "type",
84
+ "severity",
85
+ ],
86
+ unique_fields=["external_id"],
87
+ update_conflicts=True,
88
+ batch_size=10000,
89
+ )
@@ -695,6 +695,38 @@ class TestOrderProposal:
695
695
  assert order.shares == Decimal(0)
696
696
  assert order.weighting == Decimal(0)
697
697
 
698
+ def test_order_submit_bellow_minimum_weighting(self, order_factory, order_proposal):
699
+ o1 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.8"))
700
+ o2 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.2"))
701
+ order_proposal.submit()
702
+ order_proposal.save()
703
+
704
+ o1.refresh_from_db()
705
+ o2.refresh_from_db()
706
+ assert o1.weighting == Decimal("0.8")
707
+ assert o2.weighting == Decimal("0.2")
708
+
709
+ order_proposal.min_weighting = Decimal("0.21")
710
+ order_proposal.backtodraft()
711
+ order_proposal.submit()
712
+ order_proposal.save()
713
+
714
+ o1.refresh_from_db()
715
+ o2.refresh_from_db()
716
+ assert o1.weighting == Decimal("0.8")
717
+ assert o2.weighting == Decimal("0")
718
+
719
+ order_proposal.approve()
720
+ order_proposal.apply()
721
+ order_proposal.save()
722
+
723
+ assert order_proposal.portfolio.assets.get(
724
+ date=order_proposal.trade_date, underlying_quote=o1.underlying_instrument
725
+ ).weighting == Decimal("0.8")
726
+ assert order_proposal.portfolio.assets.get(
727
+ date=order_proposal.trade_date, underlying_quote=order_proposal.cash_component
728
+ ).weighting == Decimal("0.2")
729
+
698
730
  def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
699
731
  order1 = order_factory.create(
700
732
  order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")