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.
- wbportfolio/admin/orders/order_proposals.py +2 -0
- wbportfolio/admin/orders/orders.py +2 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/api_clients/ubs.py +23 -11
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +8 -3
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/orders/order_proposals.py +3 -6
- wbportfolio/filters/portfolios.py +18 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/handlers/asset_position.py +9 -5
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +2 -2
- wbportfolio/import_export/handlers/trade.py +4 -4
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/resources/trades.py +1 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +7 -3
- wbportfolio/models/builder.py +25 -5
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
- wbportfolio/models/orders/order_proposals.py +620 -490
- wbportfolio/models/orders/orders.py +237 -75
- wbportfolio/models/portfolio.py +79 -18
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +4 -1
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/claim.py +6 -5
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/trades.py +4 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +16 -0
- wbportfolio/order_routing/adapters/__init__.py +14 -6
- wbportfolio/order_routing/adapters/ubs.py +104 -70
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +115 -103
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/orders/order_proposals.py +6 -2
- wbportfolio/serializers/orders/orders.py +119 -26
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
- wbportfolio/tests/models/test_portfolios.py +9 -9
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
- wbportfolio/tests/rebalancing/test_models.py +2 -2
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +1 -1
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
- wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
- wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
- wbportfolio/viewsets/orders/order_proposals.py +92 -21
- wbportfolio/viewsets/orders/orders.py +79 -26
- wbportfolio/viewsets/portfolios.py +24 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/fdm/tasks.py +0 -42
- wbportfolio/models/orders/routing.py +0 -54
- wbportfolio/pms/trading/handler.py +0 -211
- /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
- {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 =
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
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
|
|
219
|
+
elif has_d or has_y:
|
|
219
220
|
dtype = "date"
|
|
220
|
-
elif
|
|
221
|
+
elif has_h:
|
|
221
222
|
dtype = "time"
|
|
222
|
-
elif
|
|
223
|
+
elif has_p and has_z:
|
|
223
224
|
dtype = "float"
|
|
224
|
-
elif
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
|
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",
|
wbportfolio/models/asset.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
wbportfolio/models/builder.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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,
|
|
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
|
]
|
wbportfolio/models/custodians.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
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}"
|
wbportfolio/models/exceptions.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
class
|
|
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.
|
|
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"
|