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.
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/handlers/trade.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/builder.py +70 -25
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +512 -161
- wbportfolio/models/orders/orders.py +20 -10
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +76 -41
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -0
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +23 -3
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
- wbportfolio/tests/models/test_imports.py +7 -6
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/viewsets/charts/assets.py +4 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +42 -4
- wbportfolio/viewsets/orders/orders.py +33 -31
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/RECORD +64 -54
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.4.dist-info}/licenses/LICENSE +0 -0
wbportfolio/admin/indexes.py
CHANGED
|
@@ -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
|
),
|
wbportfolio/admin/products.py
CHANGED
|
@@ -37,7 +37,7 @@ class ProductAdmin(InstrumentModelAdmin):
|
|
|
37
37
|
{
|
|
38
38
|
"fields": (
|
|
39
39
|
("share_price", "initial_high_water_mark", "bank"),
|
|
40
|
-
("
|
|
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
|
)
|
wbportfolio/admin/rebalancing.py
CHANGED
|
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.
|
|
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.
|
|
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
|
-
|
|
21
|
+
apply_order_proposal_automatically = False
|
|
22
22
|
frequency = "RRULE:FREQ=MONTHLY;"
|
|
23
23
|
activation_date = None
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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(
|
|
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"],
|
|
77
|
-
"weighting": round(position["weighting"],
|
|
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
|
|
6
|
+
from slugify import slugify
|
|
7
7
|
|
|
8
8
|
from wbportfolio.models import Trade
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def parse_row(obj: 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
|
|
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,
|
|
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),
|
|
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)
|