wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.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 (64) 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/portfolios.py +1 -1
  9. wbportfolio/factories/rebalancing.py +1 -1
  10. wbportfolio/filters/orders/__init__.py +1 -0
  11. wbportfolio/filters/orders/order_proposals.py +58 -0
  12. wbportfolio/filters/portfolios.py +20 -0
  13. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  14. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  15. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  16. wbportfolio/import_export/backends/utils.py +0 -17
  17. wbportfolio/import_export/handlers/asset_position.py +1 -1
  18. wbportfolio/import_export/handlers/trade.py +2 -2
  19. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  20. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  21. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  22. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  23. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  24. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  25. wbportfolio/models/builder.py +70 -25
  26. wbportfolio/models/mixins/instruments.py +7 -0
  27. wbportfolio/models/orders/order_proposals.py +512 -161
  28. wbportfolio/models/orders/orders.py +20 -10
  29. wbportfolio/models/orders/routing.py +54 -0
  30. wbportfolio/models/portfolio.py +76 -41
  31. wbportfolio/models/rebalancing.py +6 -6
  32. wbportfolio/models/transactions/transactions.py +10 -6
  33. wbportfolio/order_routing/__init__.py +19 -0
  34. wbportfolio/order_routing/adapters/__init__.py +57 -0
  35. wbportfolio/order_routing/adapters/ubs.py +161 -0
  36. wbportfolio/pms/trading/handler.py +4 -0
  37. wbportfolio/pms/typing.py +62 -8
  38. wbportfolio/rebalancing/models/composite.py +1 -1
  39. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  40. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  41. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  42. wbportfolio/serializers/orders/order_proposals.py +23 -3
  43. wbportfolio/serializers/orders/orders.py +5 -2
  44. wbportfolio/serializers/positions.py +2 -2
  45. wbportfolio/serializers/rebalancing.py +1 -1
  46. wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
  47. wbportfolio/tests/models/test_imports.py +7 -6
  48. wbportfolio/tests/models/test_portfolios.py +57 -23
  49. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  50. wbportfolio/tests/rebalancing/test_models.py +3 -5
  51. wbportfolio/viewsets/charts/assets.py +4 -1
  52. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  53. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  54. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  55. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
  56. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  57. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  58. wbportfolio/viewsets/orders/order_proposals.py +42 -4
  59. wbportfolio/viewsets/orders/orders.py +33 -31
  60. wbportfolio/viewsets/portfolios.py +3 -3
  61. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/METADATA +1 -1
  62. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/RECORD +64 -54
  63. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/WHEEL +0 -0
  64. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.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")
@@ -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
@@ -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)
@@ -99,7 +99,7 @@ class TradeImportHandler(ImportExportHandler):
99
99
  if history.exists():
100
100
  queryset = history
101
101
  else:
102
- queryset = self.model.objects.filter(marked_for_deletion=False)
102
+ queryset = self.model.objects.filter(marked_for_deletion=False).exclude(id__in=self.processed_ids)
103
103
 
104
104
  queryset = queryset.filter(
105
105
  models.Q(underlying_instrument=data["underlying_instrument"]) & models.Q(**dates_lookup)
@@ -116,7 +116,6 @@ class TradeImportHandler(ImportExportHandler):
116
116
  if external_id_queryset.count() == 1:
117
117
  self.import_source.log += f"External ID {external_id} provided -> Load CustomerTrade"
118
118
  return external_id_queryset.first()
119
-
120
119
  if portfolio := data.get("portfolio", None):
121
120
  queryset = queryset.filter(portfolio=portfolio)
122
121
  if queryset.exists():
@@ -142,6 +141,7 @@ class TradeImportHandler(ImportExportHandler):
142
141
  if queryset.exists():
143
142
  # We try to filter by price as well
144
143
  trade = queryset.first()
144
+
145
145
  if queryset.count() == 1:
146
146
  self.import_source.log += f"\nOne Trade found: {trade}"
147
147
  if queryset.count() > 1:
@@ -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
  }
@@ -3,15 +3,15 @@ from typing import Dict
3
3
 
4
4
  import numpy as np
5
5
  import pandas as pd
6
- from wbcore.contrib.io.models import ImportSource
6
+ from slugify import slugify
7
7
 
8
8
  from wbportfolio.models import Trade
9
9
 
10
10
 
11
- def parse_row(obj: Dict, import_source: ImportSource) -> Dict:
11
+ def parse_row(obj: Dict, negate_shares: bool = False) -> Dict:
12
12
  isin = obj["underlying_instrument__isin"]
13
13
  shares = obj["shares"]
14
- if import_source.source.import_parameters.get("negate_shares", False):
14
+ if negate_shares:
15
15
  shares = -1 * shares
16
16
  return {
17
17
  "underlying_instrument": {"isin": isin, "instrument_type": "product"},
@@ -30,6 +30,9 @@ def parse(import_source):
30
30
  xx, yy = np.where(df == "Trade Date")
31
31
  df = df.iloc[xx[0] :, yy[0] :]
32
32
  df = df.rename(columns=df.iloc[0]).drop(df.index[0]).dropna(how="all")
33
+ negate_shares = "net-quantity" in list(
34
+ map(lambda c: slugify(c), df.columns)
35
+ ) # we slugified the column to be more robust
33
36
  df = df.rename(columns=lambda x: x.lower())
34
37
  df = df.rename(
35
38
  columns={
@@ -50,6 +53,5 @@ def parse(import_source):
50
53
  df.loc[df["custodian"].isnull(), "custodian"] = "N/A"
51
54
  data = list()
52
55
  for d in df.to_dict("records"):
53
- data.append(parse_row(d, import_source))
54
-
56
+ data.append(parse_row(d, negate_shares=negate_shares))
55
57
  return {"data": data}
@@ -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)