wbportfolio 1.55.8__py2.py3-none-any.whl → 1.59.4__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 (128) hide show
  1. wbportfolio/admin/orders/order_proposals.py +2 -0
  2. wbportfolio/admin/orders/orders.py +2 -0
  3. wbportfolio/admin/portfolio.py +11 -5
  4. wbportfolio/api_clients/ubs.py +23 -11
  5. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  6. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  7. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  8. wbportfolio/contrib/company_portfolio/models.py +69 -39
  9. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  10. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  11. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  12. wbportfolio/factories/assets.py +1 -1
  13. wbportfolio/factories/orders/order_proposals.py +3 -1
  14. wbportfolio/factories/orders/orders.py +8 -3
  15. wbportfolio/factories/product_groups.py +3 -3
  16. wbportfolio/factories/products.py +3 -3
  17. wbportfolio/filters/assets.py +0 -1
  18. wbportfolio/filters/orders/order_proposals.py +3 -6
  19. wbportfolio/filters/portfolios.py +18 -1
  20. wbportfolio/filters/positions.py +0 -1
  21. wbportfolio/filters/transactions/fees.py +0 -2
  22. wbportfolio/filters/transactions/trades.py +0 -1
  23. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  24. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  25. wbportfolio/import_export/handlers/asset_position.py +9 -5
  26. wbportfolio/import_export/handlers/dividend.py +1 -1
  27. wbportfolio/import_export/handlers/fees.py +2 -2
  28. wbportfolio/import_export/handlers/trade.py +4 -4
  29. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  30. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  31. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  32. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  33. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  34. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  35. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  36. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  37. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  38. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  39. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  40. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  41. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  42. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  43. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  44. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  45. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  46. wbportfolio/import_export/resources/trades.py +1 -1
  47. wbportfolio/import_export/utils.py +3 -1
  48. wbportfolio/metric/backends/base.py +2 -2
  49. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  50. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  51. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  52. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  53. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  54. wbportfolio/models/adjustments.py +1 -1
  55. wbportfolio/models/asset.py +7 -3
  56. wbportfolio/models/builder.py +25 -5
  57. wbportfolio/models/custodians.py +3 -3
  58. wbportfolio/models/exceptions.py +1 -1
  59. wbportfolio/models/graphs/portfolio.py +1 -1
  60. wbportfolio/models/graphs/utils.py +11 -11
  61. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  62. wbportfolio/models/orders/order_proposals.py +620 -490
  63. wbportfolio/models/orders/orders.py +237 -75
  64. wbportfolio/models/portfolio.py +79 -18
  65. wbportfolio/models/portfolio_relationship.py +6 -0
  66. wbportfolio/models/products.py +3 -0
  67. wbportfolio/models/rebalancing.py +4 -1
  68. wbportfolio/models/roles.py +4 -10
  69. wbportfolio/models/transactions/claim.py +6 -5
  70. wbportfolio/models/transactions/dividends.py +1 -0
  71. wbportfolio/models/transactions/trades.py +4 -0
  72. wbportfolio/models/transactions/transactions.py +16 -4
  73. wbportfolio/models/utils.py +100 -1
  74. wbportfolio/order_routing/__init__.py +16 -0
  75. wbportfolio/order_routing/adapters/__init__.py +14 -6
  76. wbportfolio/order_routing/adapters/ubs.py +104 -70
  77. wbportfolio/order_routing/router.py +33 -0
  78. wbportfolio/order_routing/tests/test_router.py +110 -0
  79. wbportfolio/permissions.py +7 -0
  80. wbportfolio/pms/trading/__init__.py +0 -1
  81. wbportfolio/pms/trading/optimizer.py +61 -0
  82. wbportfolio/pms/typing.py +115 -103
  83. wbportfolio/rebalancing/models/composite.py +1 -1
  84. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  85. wbportfolio/risk_management/backends/__init__.py +1 -0
  86. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  87. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  88. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  89. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  90. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  91. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  92. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  93. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  94. wbportfolio/serializers/orders/order_proposals.py +6 -2
  95. wbportfolio/serializers/orders/orders.py +119 -26
  96. wbportfolio/serializers/transactions/claim.py +2 -2
  97. wbportfolio/tasks.py +42 -4
  98. wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
  99. wbportfolio/tests/models/test_portfolios.py +9 -9
  100. wbportfolio/tests/models/test_splits.py +1 -6
  101. wbportfolio/tests/models/test_utils.py +140 -0
  102. wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
  103. wbportfolio/tests/rebalancing/test_models.py +2 -2
  104. wbportfolio/tests/viewsets/test_products.py +1 -0
  105. wbportfolio/urls.py +1 -1
  106. wbportfolio/viewsets/charts/assets.py +8 -4
  107. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  108. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  109. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  110. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  111. wbportfolio/viewsets/esg.py +3 -5
  112. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
  113. wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
  114. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
  115. wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
  116. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
  117. wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
  118. wbportfolio/viewsets/orders/order_proposals.py +92 -21
  119. wbportfolio/viewsets/orders/orders.py +79 -26
  120. wbportfolio/viewsets/portfolios.py +24 -0
  121. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
  122. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
  123. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  124. wbportfolio/fdm/tasks.py +0 -42
  125. wbportfolio/models/orders/routing.py +0 -54
  126. wbportfolio/pms/trading/handler.py +0 -211
  127. /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
  128. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -10,6 +10,7 @@ files generated by other spreadsheets.
10
10
  """
11
11
 
12
12
  import re
13
+ from ast import literal_eval
13
14
 
14
15
 
15
16
  class Table:
@@ -185,7 +186,7 @@ class SYLK:
185
186
  self.cury = int(val)
186
187
 
187
188
  elif ftd == "K":
188
- val = eval(self.escape(val))
189
+ val = literal_eval(self.escape(val))
189
190
  # if type(val) == int:
190
191
  # if self.currenttype == "date":
191
192
  # # value is offset in days from datebase
@@ -208,20 +209,20 @@ class SYLK:
208
209
  self.printformats.append((format, self.knownformats[format]))
209
210
  else:
210
211
  # hack to guess type...
211
- hasY = "y" in format
212
- hasD = "d" in format
213
- hasH = "h" in format
214
- hasZ = "0" in format
215
- hasP = "." in format
216
- if (hasD or hasY) and hasH:
212
+ has_y = "y" in format
213
+ has_d = "d" in format
214
+ has_h = "h" in format
215
+ has_z = "0" in format
216
+ has_p = "." in format
217
+ if (has_d or has_y) and has_h:
217
218
  dtype = "datetime"
218
- elif hasD or hasY:
219
+ elif has_d or has_y:
219
220
  dtype = "date"
220
- elif hasH:
221
+ elif has_h:
221
222
  dtype = "time"
222
- elif hasP and hasZ:
223
+ elif has_p and has_z:
223
224
  dtype = "float"
224
- elif hasZ:
225
+ elif has_z:
225
226
  dtype = "int"
226
227
  else:
227
228
  dtype = "string"
@@ -10,8 +10,10 @@ def file_name_parse(file_name):
10
10
  dates = re.findall(r"([0-9]{4}-[0-9]{2}-[0-9]{2})", file_name)
11
11
  isin = re.findall(r"\.([a-zA-Z0-9]*)_", file_name)
12
12
 
13
- assert len(dates) == 2, "Not 2 dates found in the filename"
14
- assert len(isin) == 1, "Not exactly 1 isin found in the filename"
13
+ if len(dates) != 2:
14
+ raise ValueError("Not 2 dates found in the filename")
15
+ if len(isin) != 1:
16
+ raise ValueError("Not exactly 1 isin found in the filename")
15
17
 
16
18
  return {
17
19
  "isin": isin[0],
@@ -14,8 +14,8 @@ logger = logging.getLogger("importers.parsers.jp_morgan.strategy")
14
14
  def file_name_parse(file_name):
15
15
  dates = re.findall("([0-9]{8})", file_name)
16
16
 
17
- assert len(dates) == 1, "Not exactly 1 date found in the filename"
18
-
17
+ if len(dates) != 1:
18
+ raise ValueError("Not exactly 1 date found in the filename")
19
19
  return {"valuation_date": datetime.datetime.strptime(dates[0], "%Y%m%d").date()}
20
20
 
21
21
 
@@ -23,7 +23,7 @@ def parse(import_source):
23
23
  data = list()
24
24
  prices = list()
25
25
  df_dict = pd.read_excel(BytesIO(import_source.file.read()), engine="openpyxl", sheet_name=None)
26
- for sheet_name, df in df_dict.items():
26
+ for df in df_dict.values():
27
27
  xx, yy = np.where(df == "Ticker")
28
28
  if len(xx) == 1 and len(yy) == 1:
29
29
  df_info = df.iloc[: xx[0] - 1, :].transpose()
@@ -20,7 +20,8 @@ product_mapping = {
20
20
 
21
21
  def file_name_parse(file_name):
22
22
  identifier = re.findall("([0-9]{4}).*", file_name)
23
- assert len(identifier) == 1, "Not exactly one identifier was found."
23
+ if len(identifier) != 1:
24
+ raise ValueError("Not exactly 1 identifier found in the filename")
24
25
  return identifier[0]
25
26
 
26
27
 
@@ -7,9 +7,10 @@ import re
7
7
  def file_name_parse(file_name):
8
8
  dates = re.findall(r"([0-9]{4}-[0-9]{2}-[0-9]{2})", file_name)
9
9
  isin = re.findall(r"\.([a-zA-Z0-9]*)_", file_name)
10
-
11
- assert len(dates) == 2, "Not 2 dates found in the filename"
12
- assert len(isin) == 1, "Not exactly 1 isin found in the filename"
10
+ if len(dates) != 2:
11
+ raise ValueError("Not 2 dates found in the filename")
12
+ if len(isin) != 1:
13
+ raise ValueError("Not exactly 1 isin found in the filename")
13
14
 
14
15
  return {
15
16
  "isin": isin[0],
@@ -0,0 +1,39 @@
1
+ import json
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from django.utils.dateparse import parse_date
6
+
7
+ BASE_MAPPING = {
8
+ "instrument": "underlying_instrument__name",
9
+ "ric": "underlying_instrument__refinitiv_identifier_code",
10
+ "bbTicker": "underlying_instrument__ticker",
11
+ "isin": "underlying_instrument__isin",
12
+ "assetClass": "underlying_instrument__instrument_type",
13
+ "direction": "transaction_subtype",
14
+ "currency": "currency__key",
15
+ "quantityTraded": "shares",
16
+ "localPrice": "price",
17
+ "fxMultiplier": "currency_fx_rate",
18
+ "tradeDate": "book_date",
19
+ }
20
+
21
+
22
+ def parse(import_source):
23
+ content = json.load(import_source.file)
24
+ data = []
25
+ if (rebalances := content.get("rebalances", None)) and (isin := content.get("isin", None)):
26
+ for rebalance_data in rebalances:
27
+ rebalancing_date = parse_date(rebalance_data["rebalanceDate"])
28
+ df = pd.DataFrame(rebalance_data["items"]).replace([np.inf, -np.inf, np.nan], None)
29
+ df = df.rename(columns=BASE_MAPPING)
30
+ df = df.drop(columns=df.columns.difference(BASE_MAPPING.values()))
31
+ df["underlying_instrument__instrument_type"] = df["underlying_instrument__instrument_type"].str.lower()
32
+ df["transaction_date"] = rebalancing_date.strftime("%Y-%m-%d")
33
+ df["portfolio__isin"] = isin
34
+ df.loc[df["book_date"].isnull(), "book_date"] = df.loc[df["book_date"].isnull(), "transaction_date"]
35
+ df["currency_fx_rate"] = 1 / df["currency_fx_rate"]
36
+ df.loc[df["price"].isnull() & (df["underlying_instrument__instrument_type"] == "cash"), "price"] = 1.0
37
+ df["underlying_instrument__currency__key"] = df["currency__key"]
38
+ data.extend(df.to_dict(orient="records"))
39
+ return {"data": data}
@@ -7,7 +7,8 @@ import xlrd
7
7
  def file_name_parse(file_name):
8
8
  isin = re.findall("([A-Z]{2}[A-Z0-9]{9}[0-9]{1})", file_name)
9
9
 
10
- assert len(isin) == 1, "Not exactly 1 isin found in the filename"
10
+ if len(isin) != 1:
11
+ raise ValueError("Not exactly 1 isin found in the filename")
11
12
 
12
13
  return {"isin": isin[0]}
13
14
 
@@ -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],
@@ -19,7 +19,7 @@ class OrderProposalTradeResource(FilterModelResource):
19
19
  "underlying_instrument__isin": lambda: rstr.xeger("([A-Z]{2}[A-Z0-9]{9}[0-9]{1})"),
20
20
  "underlying_instrument__ticker": "AAA",
21
21
  "underlying_instrument__name": "stock name",
22
- "weighting": 1.0,
22
+ "weighting": 0.015,
23
23
  "shares": 1000.2536,
24
24
  "comment": lambda: fake.sentence(),
25
25
  "order": 1,
@@ -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,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
+ ]
@@ -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
+ ]
@@ -0,0 +1,32 @@
1
+ # Generated by Django 5.0.12 on 2025-11-06 12:59
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('wbportfolio', '0090_dividendtransaction_price_fx_portfolio_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='order',
15
+ name='execution_confirmed',
16
+ ),
17
+ migrations.AddField(
18
+ model_name='order',
19
+ name='execution_instruction',
20
+ field=models.CharField(choices=[('MARKET_ON_CLOSE', 'Market On Close'), ('GUARANTEED_MARKET_ON_CLOSE', 'Guaranteed Market On Close'), ('GUARANTEED_MARKET_ON_OPEN', 'Guaranteed Market On Open'), ('GPW_MARKET_ON_CLOSE', 'GPW Market On Close'), ('MARKET_ON_OPEN', 'Market On Open'), ('IN_LINE_WITH_VOLUME', 'In Line With Volume'), ('LIMIT_ORDER', 'Limit Order'), ('VWAP', 'VWAP'), ('TWAP', 'TWAP')], default='MARKET_ON_CLOSE', max_length=26, verbose_name='Execution Instruction'),
21
+ ),
22
+ migrations.AddField(
23
+ model_name='order',
24
+ name='execution_instruction_parameters',
25
+ field=models.JSONField(blank=True, default=dict, verbose_name='Execution Instruction Parameters'),
26
+ ),
27
+ migrations.AddField(
28
+ model_name='order',
29
+ name='execution_status',
30
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('CONFIRMED', 'Confirmed'), ('EXECUTED', 'Executed'), ('FAILED', 'Failed'), ('IGNORED', 'Ignored')], default='PENDING', max_length=12, verbose_name='Execution Status'),
31
+ ),
32
+ ]
@@ -0,0 +1,49 @@
1
+ # Generated by Django 5.0.12 on 2025-11-11 10:16
2
+
3
+ import django_fsm
4
+ import django.db.models.deletion
5
+ from decimal import Decimal
6
+ from django.db import migrations, models
7
+ import django_fsm
8
+ from django.db import migrations
9
+
10
+ def migrate_status(apps, schema_editor):
11
+ OrderProposal = apps.get_model("wbportfolio", "OrderProposal")
12
+ OrderProposal.objects.filter(status="APPLIED").update(status="CONFIRMED")
13
+
14
+
15
+ class Migration(migrations.Migration):
16
+
17
+ dependencies = [
18
+ ('wbportfolio', '0091_remove_order_execution_confirmed_and_more'),
19
+ ]
20
+
21
+ operations = [
22
+ migrations.AddField(
23
+ model_name='order',
24
+ name='quantization_error',
25
+ field=models.DecimalField(decimal_places=8, default=Decimal('0'), max_digits=9, verbose_name='Quantization Error'),
26
+ ),
27
+ migrations.AlterField(
28
+ model_name='orderproposal',
29
+ name='status',
30
+ field=django_fsm.FSMField(choices=[('DRAFT', 'Draft'), ('PENDING', 'Pending'), ('APPROVED', 'Approved'), ('DENIED', 'Denied'), ('EXECUTION', 'Execution'), ('APPLIED', 'Applied'), ('FAILED', 'Failed')], default='DRAFT', max_length=50, verbose_name='Status'),
31
+ ),
32
+ migrations.AlterField(
33
+ model_name='orderproposal',
34
+ name='status',
35
+ field=django_fsm.FSMField(
36
+ choices=[('DRAFT', 'Draft'), ('PENDING', 'Pending'), ('APPROVED', 'Approved'), ('DENIED', 'Denied'),
37
+ ('EXECUTION', 'Execution'), ('CONFIRMED', 'Confirmed'), ('FAILED', 'Failed')], default='DRAFT',
38
+ max_length=50, verbose_name='Status'),
39
+ ),
40
+ migrations.AddField(
41
+ model_name='order',
42
+ name='execution_trade',
43
+ field=models.OneToOneField(blank=True, help_text='The executed Trade', null=True,
44
+ on_delete=django.db.models.deletion.SET_NULL, related_name='order',
45
+ to='wbportfolio.trade'),
46
+ ),
47
+ migrations.RunPython(migrate_status),
48
+
49
+ ]
@@ -0,0 +1,35 @@
1
+ # Generated by Django 5.0.12 on 2025-11-28 13:11
2
+
3
+ from django.db import migrations, models
4
+
5
+ def handle_data(apps, schema_editor):
6
+ PortfolioPortfolioThroughModel = apps.get_model('wbportfolio', 'PortfolioPortfolioThroughModel')
7
+ PortfolioPortfolioThroughModel.objects.filter(type="PRIMARY").update(type="LOOK_THROUGH")
8
+ from wbportfolio.models.portfolio import Portfolio
9
+ for portfolio in Portfolio.objects.all():
10
+ if portfolio.assets.exists():
11
+ val_date = portfolio.assets.latest("date").date
12
+ for parent_ptf, _ in portfolio.get_parent_portfolios(val_date):
13
+ PortfolioPortfolioThroughModel.objects.get_or_create(portfolio_id=portfolio.id, dependency_portfolio_id=parent_ptf.id, defaults={"type": "HIERARCHICAL"})
14
+ class Migration(migrations.Migration):
15
+
16
+ dependencies = [
17
+ ('wbportfolio', '0092_order_quantization_error_alter_orderproposal_status'),
18
+ ]
19
+
20
+ operations = [
21
+ migrations.RemoveConstraint(
22
+ model_name='portfolioportfoliothroughmodel',
23
+ name='unique_primary',
24
+ ),
25
+ migrations.AlterField(
26
+ model_name='portfolioportfoliothroughmodel',
27
+ name='type',
28
+ field=models.CharField(choices=[('LOOK_THROUGH', 'Look-through'), ('MODEL', 'Model'), ('CUSTODIAN', 'Custodian'), ('HIERARCHICAL', 'Hierarchical')], default='LOOK_THROUGH', verbose_name='Type'),
29
+ ),
30
+ migrations.AddConstraint(
31
+ model_name='portfolioportfoliothroughmodel',
32
+ constraint=models.UniqueConstraint(condition=models.Q(('type', 'LOOK_THROUGH')), fields=('portfolio', 'type'), name='unique_lookthrough'),
33
+ ),
34
+ migrations.RunPython(handle_data)
35
+ ]
@@ -212,7 +212,7 @@ def post_adjustment_on_prices(adjustment_id, automatically_confirm_approve_adjus
212
212
  adjustment.apply_adjustment_on_assets()
213
213
  adjustment.status = Adjustment.Status.APPLIED
214
214
  else:
215
- for user in User.objects.filter(profile__in=PortfolioRole.portfolio_managers()):
215
+ for user in User.objects.filter(profile__in=PortfolioRole.portfolio_managers(), is_active=True):
216
216
  send_notification(
217
217
  code="wbportfolio.adjustment.add",
218
218
  title="A new adjustment was imported",
@@ -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):
@@ -447,7 +448,10 @@ class AssetPosition(ImportMixin, models.Model):
447
448
  net_value = self.initial_price
448
449
  # in case the position currency and the linked underlying_quote currency don't correspond, we convert the rate accordingly
449
450
  if self.currency != self.underlying_quote.currency:
450
- net_value *= self.currency.convert(self.asset_valuation_date, self.underlying_quote.currency)
451
+ with suppress(CurrencyFXRates.DoesNotExist):
452
+ net_value *= self.currency.convert(
453
+ self.asset_valuation_date, self.underlying_quote.currency
454
+ )
451
455
  self.underlying_quote_price = InstrumentPrice.objects.create(
452
456
  calculated=False,
453
457
  instrument=self.underlying_quote,
@@ -55,12 +55,18 @@ class AssetPositionBuilder:
55
55
  self._compute_metrics_tasks = set()
56
56
  self._change_at_date_tasks = dict()
57
57
  self._positions = defaultdict(dict)
58
+ self.excluded_positions = defaultdict(list)
59
+ self._change_at_date_kwargs = {}
58
60
 
59
61
  def get_positions(self, fix_quantization: bool = True, **kwargs):
60
62
  # return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
61
- for positions in self._positions.values():
63
+ for val_date, positions in self._positions.items():
64
+ excluded_positions = self.excluded_positions.get(val_date, [])
65
+ total_excluded_position_weight = (
66
+ sum(map(lambda o: o.weighting, excluded_positions)) if excluded_positions else Decimal("0")
67
+ )
62
68
  quantization_weight_error = round(
63
- Decimal("1") - sum(map(lambda o: o.weighting, positions.values()))
69
+ Decimal("1") - total_excluded_position_weight - sum(map(lambda o: o.weighting, positions.values()))
64
70
  if fix_quantization
65
71
  else Decimal("0"),
66
72
  MINIMUM_DECIMAL,
@@ -121,7 +127,10 @@ class AssetPositionBuilder:
121
127
  underlying_quote = self._get_instrument(instrument_id)
122
128
  currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
123
129
  currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
124
- price = self._get_price(val_date, underlying_quote)
130
+ if underlying_quote.is_cash:
131
+ price = Decimal("1")
132
+ else:
133
+ price = self._get_price(val_date, underlying_quote)
125
134
 
126
135
  parameters = dict(
127
136
  underlying_quote=underlying_quote,
@@ -210,6 +219,9 @@ class AssetPositionBuilder:
210
219
  ) # set the weight as it will be saved in the db to handle quantization error accordingly
211
220
  if position.initial_price is not None and position.initial_currency_fx_rate is not None:
212
221
  self._positions[position.date][key] = position
222
+ else:
223
+ self.excluded_positions[position.date].append(position)
224
+ self._change_at_date_kwargs["fix_quantization"] = True
213
225
  return self
214
226
 
215
227
  def get_dates(self) -> list[date]:
@@ -225,7 +237,6 @@ class AssetPositionBuilder:
225
237
 
226
238
  def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
227
239
  positions = list(self.get_positions(**kwargs))
228
-
229
240
  # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
230
241
  # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
231
242
  # change completely the trades of a portfolio model and drift it.
@@ -272,6 +283,9 @@ class AssetPositionBuilder:
272
283
  self._compute_metrics_tasks.add(val_date)
273
284
  self._positions = defaultdict(dict)
274
285
 
286
+ def clear(self):
287
+ self.excluded_positions = defaultdict(list)
288
+
275
289
  def schedule_metric_computation(self):
276
290
  if self._compute_metrics_tasks:
277
291
  basket_id = self.portfolio.id
@@ -287,11 +301,17 @@ class AssetPositionBuilder:
287
301
  def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
288
302
  from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
289
303
 
304
+ change_at_date_kwargs = task_kwargs
305
+ change_at_date_kwargs.update(self._change_at_date_kwargs)
290
306
  if self._change_at_date_tasks:
291
307
  tasks = chain(
292
308
  *[
293
309
  trigger_portfolio_change_as_task.si(
294
- self.portfolio.id, d, changed_portfolio=portfolio, evaluate_rebalancer=False, **task_kwargs
310
+ self.portfolio.id,
311
+ d,
312
+ changed_portfolio=portfolio,
313
+ evaluate_rebalancer=False,
314
+ **change_at_date_kwargs,
295
315
  )
296
316
  for d, portfolio in self._change_at_date_tasks.items()
297
317
  ]
@@ -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
@@ -120,7 +120,7 @@ class PortfolioGraph:
120
120
  if rel.dependency_portfolio.is_composition:
121
121
  label += " (Composition)"
122
122
 
123
- if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.PRIMARY:
123
+ if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH:
124
124
  self.graph.add_edge(
125
125
  pydot.Edge(
126
126
  str(rel.portfolio.id), str(rel.dependency_portfolio.id), label="Look-Through", style="dotted"