wbportfolio 1.54.22__py2.py3-none-any.whl → 1.55.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 (79) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,6 +10,6 @@ class IndexAdmin(InstrumentModelAdmin):
10
10
  ("Instrument Information", InstrumentModelAdmin.fieldsets[0][1]),
11
11
  (
12
12
  "Index Information",
13
- {"fields": (("net_asset_value_computation_method_path",),)},
13
+ {"fields": (("net_asset_value_computation_method_path", "order_routing_custodian_adapter"),)},
14
14
  ),
15
15
  )
@@ -30,7 +30,7 @@ class ProductGroupAdmin(InstrumentModelAdmin):
30
30
  ("type", "category", "umbrella"),
31
31
  ("management_company", "depositary", "transfer_agent", "administrator"),
32
32
  ("investment_manager", "auditor", "paying_agent"),
33
- ("net_asset_value_computation_method_path", "risk_scale"),
33
+ ("net_asset_value_computation_method_path", "order_routing_custodian_adapter", "risk_scale"),
34
34
  )
35
35
  },
36
36
  ),
@@ -37,7 +37,7 @@ class ProductAdmin(InstrumentModelAdmin):
37
37
  {
38
38
  "fields": (
39
39
  ("share_price", "initial_high_water_mark", "bank"),
40
- ("termsheet", "fee_calculation", "net_asset_value_computation_method_path"),
40
+ ("fee_calculation", "net_asset_value_computation_method_path", "order_routing_custodian_adapter"),
41
41
  (
42
42
  "white_label_customers",
43
43
  "default_account",
@@ -55,6 +55,7 @@ class ProductAdmin(InstrumentModelAdmin):
55
55
  (
56
56
  "jurisdiction",
57
57
  "dividend",
58
+ "termsheet",
58
59
  ),
59
60
  ("external_webpage", "minimum_subscription", "cut_off_time"),
60
61
  )
@@ -20,7 +20,7 @@ class RebalancerAdmin(admin.ModelAdmin):
20
20
  "rebalancing_model",
21
21
  "portfolio",
22
22
  "parameters",
23
- "approve_order_proposal_automatically",
23
+ "apply_order_proposal_automatically",
24
24
  "activation_date",
25
25
  "frequency",
26
26
  )
File without changes
@@ -0,0 +1,150 @@
1
+ from datetime import date, datetime, timedelta
2
+ from json import JSONDecodeError
3
+
4
+ import requests
5
+ from django.conf import settings
6
+ from django.utils import timezone
7
+ from requests import HTTPError
8
+
9
+
10
+ class UBSNeoAPIClient:
11
+ BASE_URL = "https://neo.ubs.com/api"
12
+ SUCCESS_VALUE = "SUCCESS"
13
+ VIRTUAL_AMC_ISIN = "TEST_API_001"
14
+
15
+ def __init__(
16
+ self,
17
+ initial_jwt_token: str,
18
+ jwt_token_expiry_timestamp: datetime | None = None,
19
+ default_execution_instruction: str = "MARKET_ON_CLOSE",
20
+ ):
21
+ """
22
+ :param service_account_id: Identifier for your UBS service account (for reference).
23
+ :param initial_jwt_token: JWT token string initially provided to authenticate API calls.
24
+ :param jwt_token_expiry_timestamp: UNIX timestamp when the token expires - to manage renewal.
25
+ """
26
+ self.jwt_token = initial_jwt_token
27
+ self.jwt_token_expiry = (
28
+ jwt_token_expiry_timestamp if jwt_token_expiry_timestamp else timezone.now() + timedelta(days=1)
29
+ )
30
+ self.default_execution_instruction = default_execution_instruction
31
+
32
+ def _is_token_expired(self) -> bool:
33
+ """Check if the current token is expired or near expiry (e.g. within 5 minutes)."""
34
+ return timezone.now() > self.jwt_token_expiry - timedelta(minutes=5)
35
+
36
+ def _renew_token(self):
37
+ """
38
+ Placeholder: Implement token renewal logic here.
39
+ This usually involves interacting with UBS Neo application or token service
40
+ before expiry to obtain a new token. This must be customized per your process.
41
+ """
42
+ raise ValueError("Token has expired. Please go to https://neo.ubs.com/ and renew it.")
43
+
44
+ def _get_headers(self) -> dict[str, str]:
45
+ """Prepare HTTP headers including Authorization bearer token."""
46
+ if self._is_token_expired():
47
+ self._renew_token()
48
+ return {"Authorization": f"Bearer {self.jwt_token}", "Content-Type": "application/json"}
49
+
50
+ def _get_json(self, response) -> dict:
51
+ try:
52
+ return response.json()
53
+ except JSONDecodeError:
54
+ return dict()
55
+
56
+ def _validate_isin(self, isin: str, test: bool = False) -> str:
57
+ # ensure the given isin can be used for rebalancing (e.g. debug or dev mode)
58
+ if test or settings.DEBUG:
59
+ return self.VIRTUAL_AMC_ISIN
60
+ return isin
61
+
62
+ def _raise_for_status(self, response):
63
+ try:
64
+ response.raise_for_status()
65
+ except HTTPError:
66
+ json_response = response.json()
67
+ raise HTTPError(json_response.get("errors", json_response.get("message")))
68
+
69
+ def get_rebalance_service_status(self) -> dict:
70
+ """Check API connection status."""
71
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/status"
72
+ response = requests.get(url, headers=self._get_headers())
73
+ self._raise_for_status(response)
74
+ return self._get_json(response)
75
+
76
+ def get_rebalance_status_for_isin(self, isin: str) -> dict:
77
+ """Check certificate accessibility and workflow status for given ISIN."""
78
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/status/{isin}"
79
+ response = requests.get(url, headers=self._get_headers())
80
+ self._raise_for_status(response)
81
+ return self._get_json(response)
82
+
83
+ def submit_rebalance(self, isin: str, items: list[dict[str, str]], test: bool = False) -> dict:
84
+ """
85
+ Submit a rebalance request.
86
+ :param isin: Certificate ISIN string.
87
+ :param orders: List of dto representing order instructions.
88
+ :param test: If True, submits to test endpoint to validate syntax only (no persistence).
89
+ """
90
+ isin = self._validate_isin(isin, test=test)
91
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/submit/{isin}"
92
+ payload = {"items": items}
93
+ response = requests.post(url, json=payload, headers=self._get_headers())
94
+ self._raise_for_status(response)
95
+ return self._get_json(response)
96
+
97
+ def save_draft(self, isin: str, items: list[dict[str, str]]) -> dict:
98
+ """Save a rebalance draft."""
99
+ isin = self._validate_isin(isin)
100
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/savedraft/{isin}"
101
+ payload = {"items": items}
102
+ response = requests.post(url, json=payload, headers=self._get_headers())
103
+ self._raise_for_status(response)
104
+ return self._get_json(response)
105
+
106
+ def cancel_rebalance(self, isin: str) -> dict:
107
+ """Cancel a rebalance request."""
108
+ isin = self._validate_isin(isin)
109
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/cancel/{isin}"
110
+ response = requests.delete(url, headers=self._get_headers())
111
+ self._raise_for_status(response)
112
+ return self._get_json(response)
113
+
114
+ def get_current_rebalance_request(self, isin: str) -> dict:
115
+ """Fetch the current rebalance request for a certificate."""
116
+ url = f"{self.BASE_URL}/ged-amc/external/rebalance/v1/currentRebalanceRequest/{isin}"
117
+ response = requests.get(url, headers=self._get_headers())
118
+ self._raise_for_status(response)
119
+ return self._get_json(response)
120
+
121
+ def get_portfolio_at_date(self, isin: str, val_date: date) -> dict:
122
+ url = f"https://neo.ubs.com/api/ged-amc/external/report/v1/valuation/{isin}/{val_date:%Y-%m-%d}"
123
+ response = requests.get(url, headers=self._get_headers())
124
+ self._raise_for_status(response)
125
+ return self._get_json(response)
126
+
127
+ def get_management_fees(self, isin: str, from_date: date, to_date: date) -> dict:
128
+ url = f"https://neo.ubs.com/api/ged-amc/external/fee/v1/management/{isin}"
129
+ response = requests.get(
130
+ url,
131
+ headers=self._get_headers(),
132
+ params={"fromDate": from_date.strftime("%Y-%m-%d"), "toDate": to_date.strftime("%Y-%m-%d")},
133
+ )
134
+ self._raise_for_status(response)
135
+ return self._get_json(response)
136
+
137
+ def get_performance_fees(self, isin: str, from_date: date, to_date: date) -> dict:
138
+ url = f"https://neo.ubs.com/api/ged-amc/external/fee/v1/performance/{isin}"
139
+ response = requests.get(
140
+ url,
141
+ headers=self._get_headers(),
142
+ params={"fromDate": from_date.strftime("%Y-%m-%d"), "toDate": to_date.strftime("%Y-%m-%d")},
143
+ )
144
+ self._raise_for_status(response)
145
+ return self._get_json(response)
146
+
147
+ def validate_response(self, response: dict) -> dict:
148
+ if response.get("status", "") == self.SUCCESS_VALUE:
149
+ return response
150
+ return dict()
@@ -1,7 +1,9 @@
1
1
  import factory
2
2
  from faker import Faker
3
3
  from pandas._libs.tslibs.offsets import BDay
4
+ from wbcore.contrib.currency.factories import CurrencyFactory
4
5
 
6
+ from wbportfolio.factories import PortfolioFactory
5
7
  from wbportfolio.models import OrderProposal
6
8
 
7
9
  fake = Faker()
@@ -13,5 +15,5 @@ class OrderProposalFactory(factory.django.DjangoModelFactory):
13
15
 
14
16
  trade_date = factory.LazyAttribute(lambda o: (fake.date_object() + BDay(1)).date())
15
17
  comment = factory.Faker("paragraph")
16
- portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
18
+ portfolio = factory.LazyAttribute(lambda o: PortfolioFactory.create(currency=CurrencyFactory.create(key="USD")))
17
19
  creator = factory.SubFactory("wbcore.contrib.directory.factories.PersonFactory")
@@ -1,8 +1,8 @@
1
- import random
2
1
  from decimal import Decimal
3
2
 
4
3
  import factory
5
4
  from faker import Faker
5
+ from wbfdm.factories import InstrumentPriceFactory
6
6
 
7
7
  from wbportfolio.models import Order
8
8
 
@@ -18,4 +18,12 @@ class OrderFactory(factory.django.DjangoModelFactory):
18
18
  fees = Decimal(0.0)
19
19
  underlying_instrument = factory.SubFactory("wbfdm.factories.InstrumentFactory")
20
20
  shares = factory.Faker("pydecimal", min_value=10, max_value=1000, right_digits=4)
21
- price = factory.LazyAttribute(lambda o: random.randint(10, 10000))
21
+
22
+ @factory.post_generation
23
+ def create_price(self, create, extracted, **kwargs):
24
+ if create:
25
+ p = InstrumentPriceFactory.create(
26
+ instrument=self.underlying_instrument, date=self.value_date, calculated=False
27
+ )
28
+ self.price = p.net_value
29
+ self.save()
@@ -15,7 +15,7 @@ class PortfolioFactory(factory.django.DjangoModelFactory):
15
15
  model = Portfolio
16
16
 
17
17
  name = factory.Sequence(lambda n: f"Portfolio {n}")
18
- currency = factory.SubFactory("wbcore.contrib.currency.factories.CurrencyFactory")
18
+ currency = factory.SubFactory("wbcore.contrib.currency.factories.CurrencyUSDFactory")
19
19
  is_manageable = True
20
20
  is_tracked = True
21
21
  is_lookthrough = False
@@ -18,6 +18,6 @@ class RebalancerFactory(factory.django.DjangoModelFactory):
18
18
  portfolio = factory.SubFactory("wbportfolio.factories.portfolios.PortfolioFactory")
19
19
  rebalancing_model = factory.SubFactory(RebalancingModelFactory)
20
20
  parameters = dict()
21
- approve_order_proposal_automatically = False
21
+ apply_order_proposal_automatically = False
22
22
  frequency = "RRULE:FREQ=MONTHLY;"
23
23
  activation_date = None
@@ -400,8 +400,8 @@ class DistributionFilter(wb_filters.FilterSet):
400
400
 
401
401
  group_by = wb_filters.ChoiceFilter(
402
402
  label="Group By",
403
- choices=AssetPositionGroupBy.choices(),
404
- initial=AssetPositionGroupBy.INDUSTRY.name,
403
+ choices=AssetPositionGroupBy.choices,
404
+ initial=AssetPositionGroupBy.INDUSTRY.value,
405
405
  method="fake_filter",
406
406
  clearable=False,
407
407
  required=True,
@@ -427,6 +427,14 @@ class DistributionFilter(wb_filters.FilterSet):
427
427
  endpoint=ClassificationGroup.get_representation_endpoint(),
428
428
  value_key=ClassificationGroup.get_representation_value_key(),
429
429
  label_key=ClassificationGroup.get_representation_label_key(),
430
+ depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
431
+ )
432
+
433
+ group_by_classification_height = wb_filters.NumberFilter(
434
+ method="fake_filter",
435
+ label="Classification Height",
436
+ initial=0,
437
+ depends_on=[{"field": "group_by", "options": {"activates_on": [AssetPositionGroupBy.INDUSTRY.value]}}],
430
438
  )
431
439
 
432
440
  class Meta:
@@ -1 +1,2 @@
1
1
  from .orders import OrderFilterSet
2
+ from .order_proposals import OrderProposalFilterSet
@@ -0,0 +1,58 @@
1
+ from django.db.models import Exists, OuterRef
2
+ from wbcore import filters as wb_filters
3
+
4
+ from wbportfolio.models import InstrumentPortfolioThroughModel, OrderProposal
5
+
6
+
7
+ class OrderProposalFilterSet(wb_filters.FilterSet):
8
+ has_custodian_adapter = wb_filters.BooleanFilter(
9
+ method="filter_has_custodian_adapter", label="Has Custodian Adapter"
10
+ )
11
+ waiting_for_input = wb_filters.BooleanFilter(method="filter_waiting_for_input", label="Waiting for Input")
12
+ is_automatic_rebalancing = wb_filters.BooleanFilter(
13
+ method="filter_is_automatic_rebalancing", label="Automatic Rebalancing"
14
+ )
15
+
16
+ def filter_has_custodian_adapter(self, queryset, name, value):
17
+ queryset = queryset.annotate(
18
+ has_custodian_adapter=Exists(
19
+ InstrumentPortfolioThroughModel.objects.filter(
20
+ portfolio=OuterRef("portfolio"), instrument__net_asset_value_computation_method_path__isnull=False
21
+ )
22
+ )
23
+ )
24
+ if value is True:
25
+ queryset = queryset.filter(has_custodian_adapter=True)
26
+ elif value is False:
27
+ queryset = queryset.filter(has_custodian_adapter=False)
28
+ return queryset
29
+
30
+ def filter_waiting_for_input(self, queryset, name, value):
31
+ if value is True:
32
+ queryset = queryset.filter(
33
+ status__in=[OrderProposal.Status.PENDING, OrderProposal.Status.DRAFT, OrderProposal.Status.APPROVED]
34
+ )
35
+ elif value is False:
36
+ queryset = queryset.exclude(
37
+ status__in=[OrderProposal.Status.PENDING, OrderProposal.Status.DRAFT, OrderProposal.Status.APPROVED]
38
+ )
39
+ return queryset
40
+
41
+ def filter_is_automatic_rebalancing(self, queryset, name, value):
42
+ if value is True:
43
+ queryset = queryset.filter(rebalancing_model__isnull=False)
44
+ elif value is False:
45
+ queryset = queryset.filter(rebalancing_model__isnull=True)
46
+ return queryset
47
+
48
+ class Meta:
49
+ model = OrderProposal
50
+ fields = {
51
+ "trade_date": ["exact"],
52
+ "status": ["exact"],
53
+ "rebalancing_model": ["exact"],
54
+ "portfolio": ["exact"],
55
+ "creator": ["exact"],
56
+ "approver": ["exact"],
57
+ "execution_status": ["exact"],
58
+ }
@@ -1,3 +1,5 @@
1
+ from datetime import date, timedelta
2
+
1
3
  from wbcore import filters as wb_filters
2
4
  from wbfdm.models import Instrument
3
5
 
@@ -16,6 +18,24 @@ class PortfolioFilterSet(wb_filters.FilterSet):
16
18
  filter_params={"is_managed": True},
17
19
  method="filter_instrument",
18
20
  )
21
+ modeled_after = wb_filters.ModelChoiceFilter(
22
+ label="Modeled After",
23
+ queryset=Portfolio.objects.all(),
24
+ endpoint=Portfolio.get_representation_endpoint(),
25
+ value_key=Portfolio.get_representation_value_key(),
26
+ label_key=Portfolio.get_representation_label_key(),
27
+ method="filter_modeled_after",
28
+ )
29
+
30
+ def filter_modeled_after(self, queryset, name, value):
31
+ if value:
32
+ modeled_after_portfolio_ids = list(
33
+ map(
34
+ lambda p: p.portfolio.id, value.get_model_portfolio_relationships(date.today() - timedelta(days=7))
35
+ )
36
+ )
37
+ return queryset.filter(id__in=modeled_after_portfolio_ids)
38
+ return queryset
19
39
 
20
40
  def filter_instrument(self, queryset, name, value):
21
41
  if value:
@@ -7,7 +7,8 @@ from django.db import models
7
7
  from pandas.tseries.offsets import BDay
8
8
  from wbcore.contrib.io.backends import AbstractDataBackend, register
9
9
 
10
- from ..utils import process_request
10
+ from wbportfolio.api_clients.ubs import UBSNeoAPIClient
11
+
11
12
  from .mixin import DataBackendMixin
12
13
 
13
14
 
@@ -21,7 +22,8 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
21
22
  self.ubs_bank = ubs_bank
22
23
  if not import_credential or not import_credential.authentication_token:
23
24
  raise ValueError("UBS backend needs a valid import credential object")
24
- self.authentication_token = import_credential.authentication_token
25
+ self.authentication_token = import_credential.authentication_token.replace("Bearer ", "")
26
+ self.token_expiry_date = import_credential.validity_end
25
27
 
26
28
  def get_files(
27
29
  self,
@@ -31,13 +33,10 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
31
33
  **kwargs,
32
34
  ) -> BytesIO:
33
35
  execution_date = (execution_time - BDay(1)).date()
34
-
35
- endpoint = "https://neo.ubs.com/api/ged-amc/external/report/v1/valuation/{0}/{1}"
36
36
  if obj_external_ids:
37
+ client = UBSNeoAPIClient(self.authentication_token, self.token_expiry_date)
37
38
  for external_id in obj_external_ids:
38
- res_json = process_request(
39
- self.authentication_token, endpoint.format(external_id, execution_date.strftime("%Y-%m-%d"))
40
- )
39
+ res_json = client.validate_response(client.get_portfolio_at_date(external_id, execution_date))
41
40
  if res_json:
42
41
  content_file = BytesIO()
43
42
  content_file.write(json.dumps(res_json).encode())
@@ -7,7 +7,8 @@ from django.db import models
7
7
  from dynamic_preferences.registries import global_preferences_registry
8
8
  from wbcore.contrib.io.backends import AbstractDataBackend, register
9
9
 
10
- from ..utils import process_request
10
+ from wbportfolio.api_clients.ubs import UBSNeoAPIClient
11
+
11
12
  from .mixin import DataBackendMixin
12
13
 
13
14
 
@@ -21,7 +22,8 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
21
22
  self.ubs_bank = ubs_bank
22
23
  if not import_credential or not import_credential.authentication_token:
23
24
  raise ValueError("UBS backend needs a valid import credential object")
24
- self.authentication_token = import_credential.authentication_token
25
+ self.authentication_token = import_credential.authentication_token.replace("Bearer ", "")
26
+ self.token_expiry_date = import_credential.validity_end
25
27
 
26
28
  def get_files(
27
29
  self,
@@ -30,32 +32,20 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
30
32
  **kwargs,
31
33
  ) -> BytesIO:
32
34
  execution_date = execution_time.date()
33
-
34
- mngt_fees_endpoint = "https://neo.ubs.com/api/ged-amc/external/fee/v1/management/{0}"
35
- perf_fees_endpoint = "https://neo.ubs.com/api/ged-amc/external/fee/v1/performance/{0}"
36
35
  if obj_external_ids:
36
+ client = UBSNeoAPIClient(self.authentication_token, self.token_expiry_date)
37
+ start = kwargs.get("start", None)
38
+ if not start:
39
+ start = global_preferences_registry.manager()["wbfdm__default_start_date_historical_import"]
37
40
  for external_id in obj_external_ids:
38
- start = kwargs.get("start", None)
39
- if not start:
40
- start = global_preferences_registry.manager()["wbfdm__default_start_date_historical_import"]
41
- mngt_res = process_request(
42
- self.authentication_token,
43
- mngt_fees_endpoint.format(external_id),
44
- {"fromDate": start.strftime("%Y-%m-%d"), "toDate": execution_date.strftime("%Y-%m-%d")},
45
- )
46
- perf_res = process_request(
47
- self.authentication_token,
48
- perf_fees_endpoint.format(external_id),
49
- {"fromDate": start.strftime("%Y-%m-%d"), "toDate": execution_date.strftime("%Y-%m-%d")},
50
- )
51
-
41
+ mngt_res = client.validate_response(client.get_management_fees(external_id, start, execution_date))
42
+ perf_res = client.validate_response(client.get_performance_fees(external_id, start, execution_date))
52
43
  if mngt_res or perf_res:
53
44
  res_json = {
54
45
  "performance_fees": perf_res.get("fees", []),
55
46
  "management_fees": mngt_res.get("fees", []),
56
47
  "isin": external_id,
57
48
  }
58
-
59
49
  if res_json:
60
50
  content_file = BytesIO()
61
51
  content_file.write(json.dumps(res_json).encode())
@@ -7,7 +7,8 @@ from django.db import models
7
7
  from pandas.tseries.offsets import BDay
8
8
  from wbcore.contrib.io.backends import AbstractDataBackend, register
9
9
 
10
- from ..utils import process_request
10
+ from wbportfolio.api_clients.ubs import UBSNeoAPIClient
11
+
11
12
  from .mixin import DataBackendMixin
12
13
 
13
14
 
@@ -21,7 +22,8 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
21
22
  self.ubs_bank = ubs_bank
22
23
  if not import_credential or not import_credential.authentication_token:
23
24
  raise ValueError("UBS backend needs a valid import credential object")
24
- self.authentication_token = import_credential.authentication_token
25
+ self.authentication_token = import_credential.authentication_token.replace("Bearer ", "")
26
+ self.token_expiry_date = import_credential.validity_end
25
27
 
26
28
  def get_files(
27
29
  self,
@@ -30,12 +32,10 @@ class DataBackend(DataBackendMixin, AbstractDataBackend):
30
32
  **kwargs,
31
33
  ) -> BytesIO:
32
34
  execution_date = (execution_time - BDay(1)).date()
33
- endpoint = "https://neo.ubs.com/api/ged-amc/external/report/v1/valuation/{0}/{1}"
34
35
  if obj_external_ids:
36
+ client = UBSNeoAPIClient(self.authentication_token, self.token_expiry_date)
35
37
  for external_id in obj_external_ids:
36
- res_json = process_request(
37
- self.authentication_token, endpoint.format(external_id, execution_date.strftime("%Y-%m-%d"))
38
- )
38
+ res_json = client.validate_response(client.get_portfolio_at_date(external_id, execution_date))
39
39
  res_json.pop("constituents", None)
40
40
  if res_json:
41
41
  content_file = BytesIO()
@@ -1,7 +1,3 @@
1
- from contextlib import suppress
2
-
3
- import pandas as pd
4
- import requests
5
1
  from django.db.models import Q
6
2
  from dynamic_preferences.registries import global_preferences_registry
7
3
  from wbfdm.models import Instrument
@@ -11,19 +7,6 @@ def get_timedelta_import_instrument_price():
11
7
  return global_preferences_registry.manager()["wbportfolio__timedelta_import_instrument_price"]
12
8
 
13
9
 
14
- def process_request(authentication_token: str, endpoint: str | None = None, kwargs={}) -> pd.DataFrame:
15
- headers = {"Authorization": authentication_token}
16
- r = requests.get(endpoint, params=kwargs, headers=headers)
17
- if r.status_code == requests.codes.ok:
18
- with suppress(
19
- requests.exceptions.JSONDecodeError
20
- ): # we catch any json decode error because the UBS api doesn't seem to respect HTTP status code rule (i.e. returns 200 even though the http content is malformed)
21
- r_json = r.json()
22
- if r_json.get("status", "") == "SUCCESS":
23
- return r_json
24
- raise ValueError(f"Issue while processing request: {r.content}")
25
-
26
-
27
10
  def filter_active_instruments(_date, queryset=None):
28
11
  if not queryset:
29
12
  queryset = Instrument.objects
@@ -146,7 +146,7 @@ class AssetPositionImportHandler(ImportExportHandler):
146
146
  for position in leftovers_positions:
147
147
  position.delete()
148
148
  for val_date in sorted(dates):
149
- trigger_portfolio_change_as_task.delay(portfolio.id, val_date, recompute_weighting=True)
149
+ trigger_portfolio_change_as_task.delay(portfolio.id, val_date, fix_quantization=True)
150
150
 
151
151
  # check if portfolio as custodian
152
152
  latest_date = max(dates)
@@ -32,7 +32,7 @@ class OrderImportHandler(ImportExportHandler):
32
32
  self.order_proposal = OrderProposal.objects.get(id=data.pop("order_proposal_id"))
33
33
  weighting = data.get("target_weight", data.get("weighting"))
34
34
  shares = data.get("target_shares", data.get("shares", 0))
35
- if not weighting:
35
+ if weighting is None:
36
36
  raise DeserializationError("We couldn't figure out the target weight column")
37
37
  position_dto = Position(
38
38
  underlying_instrument=underlying_instrument.id,
@@ -76,7 +76,7 @@ def parse(import_source):
76
76
  }
77
77
 
78
78
  df["date"] = df["date"].apply(lambda x: x.replace("/", "-"))
79
- df["initial_currency_fx_rate"] = df["initial_currency_fx_rate"].apply(lambda x: 1 / x if x else 1).round(5)
79
+ df["initial_currency_fx_rate"] = df["initial_currency_fx_rate"].apply(lambda x: 1 / x if x else 1).round(14)
80
80
 
81
81
  cash_mask = df["Accounting category"].isin(["T111"])
82
82
  cash = (
@@ -73,8 +73,8 @@ def parse(import_source):
73
73
  "exchange": exchange,
74
74
  "asset_type": "equity",
75
75
  "currency__key": position["currency__key"],
76
- "initial_currency_fx_rate": round(position["initial_currency_fx_rate"], 6),
77
- "weighting": round(position["weighting"], 6),
76
+ "initial_currency_fx_rate": round(position["initial_currency_fx_rate"], 14),
77
+ "weighting": round(position["weighting"], 8),
78
78
  "initial_price": round(position["initial_price"], 6),
79
79
  "date": valuation_date.strftime("%Y-%m-%d"),
80
80
  }
@@ -40,7 +40,7 @@ def parse(import_source):
40
40
  for row in range(first_row, last_row, 1):
41
41
  initial_shares = round(equity_sheet.cell_value(row, 8), 4)
42
42
  close = round(equity_sheet.cell_value(row, 9), 4)
43
- initial_currency_fx_rate = round(equity_sheet.cell_value(row, 11), 4)
43
+ initial_currency_fx_rate = round(equity_sheet.cell_value(row, 11), 14)
44
44
 
45
45
  bbg = equity_sheet.cell_value(row, 3)
46
46
  ric = equity_sheet.cell_value(row, 2)
@@ -0,0 +1,19 @@
1
+ # Generated by Django 5.0.14 on 2025-08-04 09:30
2
+
3
+ from decimal import Decimal
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('wbportfolio', '0085_order_desired_target_weight'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='orderproposal',
16
+ name='total_cash_weight',
17
+ field=models.DecimalField(decimal_places=4, default=Decimal('0'), 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%.', max_digits=5, verbose_name='Total Cash Weight'),
18
+ ),
19
+ ]