wbportfolio 1.55.9__py2.py3-none-any.whl → 1.56.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/api_clients/ubs.py +11 -9
- wbportfolio/contrib/company_portfolio/configs/display.py +4 -4
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/handlers/asset_position.py +3 -3
- 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 +2 -2
- 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 +21 -3
- 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/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/models/asset.py +3 -2
- wbportfolio/models/builder.py +0 -1
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.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 +11 -8
- wbportfolio/models/orders/orders.py +84 -62
- wbportfolio/models/portfolio.py +7 -7
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +3 -0
- 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 +1 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/pms/typing.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/controversy_portfolio.py +1 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +2 -2
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -1
- 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/orders.py +56 -23
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +27 -7
- wbportfolio/tests/models/test_portfolios.py +5 -5
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/orders/orders.py +22 -4
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/RECORD +73 -72
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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],
|
|
@@ -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,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
|
+
]
|
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):
|
wbportfolio/models/builder.py
CHANGED
|
@@ -225,7 +225,6 @@ class AssetPositionBuilder:
|
|
|
225
225
|
|
|
226
226
|
def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
|
|
227
227
|
positions = list(self.get_positions(**kwargs))
|
|
228
|
-
|
|
229
228
|
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
230
229
|
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
231
230
|
# change completely the trades of a portfolio model and drift it.
|
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
|
|
@@ -3,21 +3,21 @@ import plotly.graph_objects as go
|
|
|
3
3
|
from networkx.drawing.nx_agraph import graphviz_layout
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def reformat_graph_layout(
|
|
6
|
+
def reformat_graph_layout(g, layout):
|
|
7
7
|
"""
|
|
8
8
|
this method provide positions based on layout algorithm
|
|
9
|
-
:param
|
|
9
|
+
:param g:
|
|
10
10
|
:param layout:
|
|
11
11
|
:return:
|
|
12
12
|
"""
|
|
13
13
|
if layout == "graphviz":
|
|
14
|
-
positions = graphviz_layout(
|
|
14
|
+
positions = graphviz_layout(g)
|
|
15
15
|
elif layout == "spring":
|
|
16
|
-
positions = nx.fruchterman_reingold_layout(
|
|
16
|
+
positions = nx.fruchterman_reingold_layout(g, k=0.5, iterations=1000)
|
|
17
17
|
elif layout == "spectral":
|
|
18
|
-
positions = nx.spectral_layout(
|
|
18
|
+
positions = nx.spectral_layout(g, scale=0.1)
|
|
19
19
|
elif layout == "random":
|
|
20
|
-
positions = nx.random_layout(
|
|
20
|
+
positions = nx.random_layout(g)
|
|
21
21
|
else:
|
|
22
22
|
raise Exception("please specify the layout from graphviz, spring, spectral or random")
|
|
23
23
|
|
|
@@ -25,7 +25,7 @@ def reformat_graph_layout(G, layout):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def networkx_graph_to_plotly(
|
|
28
|
-
|
|
28
|
+
g: nx.Graph,
|
|
29
29
|
labels: dict[str, str] | None = None,
|
|
30
30
|
node_size: int = 10,
|
|
31
31
|
edge_weight: int = 1,
|
|
@@ -36,12 +36,12 @@ def networkx_graph_to_plotly(
|
|
|
36
36
|
"""
|
|
37
37
|
Visualize a NetworkX graph using Plotly.
|
|
38
38
|
"""
|
|
39
|
-
positions = reformat_graph_layout(
|
|
39
|
+
positions = reformat_graph_layout(g, layout)
|
|
40
40
|
if not labels:
|
|
41
41
|
labels = {}
|
|
42
42
|
# Initialize edge traces
|
|
43
43
|
edge_traces = []
|
|
44
|
-
for edge in
|
|
44
|
+
for edge in g.edges():
|
|
45
45
|
x0, y0 = positions[edge[0]]
|
|
46
46
|
x1, y1 = positions[edge[1]]
|
|
47
47
|
|
|
@@ -52,12 +52,12 @@ def networkx_graph_to_plotly(
|
|
|
52
52
|
|
|
53
53
|
# Initialize node trace
|
|
54
54
|
node_x, node_y, node_colors, node_labels = [], [], [], []
|
|
55
|
-
for node in
|
|
55
|
+
for node in g.nodes():
|
|
56
56
|
x, y = positions[node]
|
|
57
57
|
node_x.append(x)
|
|
58
58
|
node_y.append(y)
|
|
59
59
|
node_labels.append(labels.get(node, node))
|
|
60
|
-
node_colors.append(len(list(
|
|
60
|
+
node_colors.append(len(list(g.neighbors(node)))) # Color based on degree
|
|
61
61
|
|
|
62
62
|
node_trace = go.Scatter(
|
|
63
63
|
x=node_x,
|
|
@@ -834,7 +834,7 @@ class LiquidityStressMixin:
|
|
|
834
834
|
|
|
835
835
|
""" The main function for the liquidity stress tests """
|
|
836
836
|
|
|
837
|
-
def liquidity_stress_test(
|
|
837
|
+
def liquidity_stress_test( # noqa: C901
|
|
838
838
|
self,
|
|
839
839
|
report_date: Optional[date] = None,
|
|
840
840
|
weights_date: Optional[date] = None,
|
|
@@ -527,10 +527,11 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
527
527
|
total_target_weight=self.total_expected_target_weight,
|
|
528
528
|
)
|
|
529
529
|
leftovers_orders = self.orders.all()
|
|
530
|
+
portfolio_value = self.portfolio_total_asset_value
|
|
530
531
|
for underlying_instrument_id, order_dto in service.trades_batch.trades_map.items():
|
|
531
532
|
with suppress(Order.DoesNotExist):
|
|
532
533
|
order = self.orders.get(underlying_instrument_id=underlying_instrument_id)
|
|
533
|
-
order.
|
|
534
|
+
order.set_weighting(round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION), portfolio_value)
|
|
534
535
|
order.save()
|
|
535
536
|
leftovers_orders = leftovers_orders.exclude(id=order.id)
|
|
536
537
|
leftovers_orders.delete()
|
|
@@ -539,11 +540,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
539
540
|
def fix_quantization(self):
|
|
540
541
|
if self.orders.exists():
|
|
541
542
|
orders = self.get_orders()
|
|
543
|
+
portfolio_value = self.portfolio_total_asset_value
|
|
542
544
|
t_weight = orders.aggregate(models.Sum("target_weight"))["target_weight__sum"] or Decimal("0.0")
|
|
543
545
|
# we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
|
|
544
546
|
if quantize_error := (t_weight - self.total_expected_target_weight):
|
|
545
547
|
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("target_weight")
|
|
546
|
-
biggest_order.weighting
|
|
548
|
+
biggest_order.set_weighting(biggest_order.weighting - quantize_error, portfolio_value)
|
|
547
549
|
biggest_order.save()
|
|
548
550
|
|
|
549
551
|
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
@@ -594,6 +596,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
594
596
|
orders = service.trades_batch.trades_map.values()
|
|
595
597
|
|
|
596
598
|
objs = []
|
|
599
|
+
portfolio_value = self.portfolio_total_asset_value
|
|
597
600
|
for order_dto in orders:
|
|
598
601
|
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
599
602
|
currency_fx_rate = instrument.currency.convert(
|
|
@@ -604,7 +607,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
604
607
|
daily_return = order_dto.daily_return
|
|
605
608
|
try:
|
|
606
609
|
order = self.orders.get(underlying_instrument=instrument)
|
|
607
|
-
order.weighting = weighting
|
|
608
610
|
order.currency_fx_rate = currency_fx_rate
|
|
609
611
|
order.daily_return = daily_return
|
|
610
612
|
except Order.DoesNotExist:
|
|
@@ -616,11 +618,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
616
618
|
daily_return=daily_return,
|
|
617
619
|
currency_fx_rate=currency_fx_rate,
|
|
618
620
|
)
|
|
621
|
+
order.pre_save()
|
|
622
|
+
order.set_weighting(weighting, portfolio_value)
|
|
619
623
|
order.desired_target_weight = order_dto.target_weight
|
|
620
624
|
order.order_type = Order.get_type(
|
|
621
625
|
weighting, round(order_dto.previous_weight, 8), round(order_dto.target_weight, 8)
|
|
622
626
|
)
|
|
623
|
-
order.pre_save()
|
|
624
627
|
# # if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
625
628
|
# if not order.price and order.weighting > 0:
|
|
626
629
|
# order.price = Decimal("0.0")
|
|
@@ -638,7 +641,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
638
641
|
"price",
|
|
639
642
|
"price_gross",
|
|
640
643
|
"desired_target_weight",
|
|
641
|
-
|
|
644
|
+
"shares",
|
|
642
645
|
],
|
|
643
646
|
unique_fields=["order_proposal", "underlying_instrument"],
|
|
644
647
|
update_conflicts=True,
|
|
@@ -675,7 +678,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
675
678
|
except (ValidationError, DatabaseError) as e:
|
|
676
679
|
self.status = OrderProposal.Status.FAILED
|
|
677
680
|
if not silent_exception:
|
|
678
|
-
raise ValidationError(e)
|
|
681
|
+
raise ValidationError(e) from e
|
|
679
682
|
return
|
|
680
683
|
logger.info("Submitting order proposal ...")
|
|
681
684
|
self.submit()
|
|
@@ -790,7 +793,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
790
793
|
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
791
794
|
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
792
795
|
)
|
|
793
|
-
|
|
794
796
|
# If the price is valid, calculate and return the estimated shares
|
|
795
797
|
if price_fx_portfolio:
|
|
796
798
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
@@ -888,6 +890,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
888
890
|
order_warnings = order.submit(
|
|
889
891
|
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
890
892
|
)
|
|
893
|
+
|
|
891
894
|
if order_warnings:
|
|
892
895
|
orders_validation_warnings.extend(order_warnings)
|
|
893
896
|
orders.append(order)
|
|
@@ -1193,7 +1196,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1193
1196
|
return warning
|
|
1194
1197
|
|
|
1195
1198
|
def can_cancelexecution(self):
|
|
1196
|
-
if
|
|
1199
|
+
if self.execution_status not in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
|
|
1197
1200
|
return {"execution_status": "Execution can only be cancelled if it is not already executed"}
|
|
1198
1201
|
|
|
1199
1202
|
def update_execution_status(self):
|
|
@@ -71,44 +71,25 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
71
71
|
execution_confirmed = models.BooleanField(default=False, verbose_name="Execution Confirmed")
|
|
72
72
|
execution_comment = models.TextField(default="", blank=True, verbose_name="Execution Comment")
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
warnings = []
|
|
74
|
+
order_with_respect_to = "order_proposal"
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
self.shares = Decimal("0")
|
|
95
|
-
self.weighting = Decimal("0")
|
|
96
|
-
if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
|
|
97
|
-
warnings.append(
|
|
98
|
-
f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
|
|
99
|
-
)
|
|
100
|
-
self.shares = Decimal("0")
|
|
101
|
-
self.weighting = Decimal("0")
|
|
102
|
-
if not self.price:
|
|
103
|
-
warnings.append(f"No price for {self.underlying_instrument.computed_str}")
|
|
104
|
-
if (
|
|
105
|
-
not self.underlying_instrument.is_cash
|
|
106
|
-
and not self.underlying_instrument.is_cash_equivalent
|
|
107
|
-
and self._target_weight < -1e-8
|
|
108
|
-
): # any value below -1e8 will be considered zero
|
|
109
|
-
warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
|
|
110
|
-
self.desired_target_weight = self._target_weight
|
|
111
|
-
return warnings
|
|
76
|
+
class Meta(OrderedModel.Meta):
|
|
77
|
+
verbose_name = "Order"
|
|
78
|
+
verbose_name_plural = "Orders"
|
|
79
|
+
indexes = [
|
|
80
|
+
models.Index(fields=["order_proposal"]),
|
|
81
|
+
models.Index(fields=["underlying_instrument", "value_date"]),
|
|
82
|
+
models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
|
|
83
|
+
models.Index(fields=["order_proposal", "underlying_instrument"]),
|
|
84
|
+
# models.Index(fields=["date", "underlying_instrument"]),
|
|
85
|
+
]
|
|
86
|
+
constraints = [
|
|
87
|
+
models.UniqueConstraint(
|
|
88
|
+
fields=["order_proposal", "underlying_instrument"],
|
|
89
|
+
name="unique_order",
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
# notification_email_template = "portfolio/email/trade_notification.html"
|
|
112
93
|
|
|
113
94
|
@property
|
|
114
95
|
def product(self):
|
|
@@ -176,25 +157,9 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
176
157
|
def _target_shares(self) -> Decimal:
|
|
177
158
|
return getattr(self, "target_shares", self._effective_shares + self.shares)
|
|
178
159
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
verbose_name = "Order"
|
|
183
|
-
verbose_name_plural = "Orders"
|
|
184
|
-
indexes = [
|
|
185
|
-
models.Index(fields=["order_proposal"]),
|
|
186
|
-
models.Index(fields=["underlying_instrument", "value_date"]),
|
|
187
|
-
models.Index(fields=["portfolio", "underlying_instrument", "value_date"]),
|
|
188
|
-
models.Index(fields=["order_proposal", "underlying_instrument"]),
|
|
189
|
-
# models.Index(fields=["date", "underlying_instrument"]),
|
|
190
|
-
]
|
|
191
|
-
constraints = [
|
|
192
|
-
models.UniqueConstraint(
|
|
193
|
-
fields=["order_proposal", "underlying_instrument"],
|
|
194
|
-
name="unique_order",
|
|
195
|
-
),
|
|
196
|
-
]
|
|
197
|
-
# notification_email_template = "portfolio/email/trade_notification.html"
|
|
160
|
+
def __str__(self):
|
|
161
|
+
ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
|
|
162
|
+
return f"{ticker}{self.weighting}"
|
|
198
163
|
|
|
199
164
|
def pre_save(self):
|
|
200
165
|
self.portfolio = self.order_proposal.portfolio
|
|
@@ -240,15 +205,72 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
240
205
|
else:
|
|
241
206
|
return Order.Type.SELL
|
|
242
207
|
|
|
243
|
-
def set_type(self):
|
|
244
|
-
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
245
|
-
|
|
246
208
|
def get_price(self) -> Decimal:
|
|
247
209
|
try:
|
|
248
210
|
return self.underlying_instrument.get_price(self.value_date)
|
|
249
211
|
except ValueError:
|
|
250
212
|
return Decimal("0")
|
|
251
213
|
|
|
252
|
-
def
|
|
253
|
-
|
|
254
|
-
|
|
214
|
+
def set_type(self):
|
|
215
|
+
self.order_type = self.get_type(self.weighting, self._previous_weight, self._target_weight)
|
|
216
|
+
|
|
217
|
+
def set_weighting(self, weighting: Decimal, portfolio_value: Decimal):
|
|
218
|
+
self.weighting = weighting
|
|
219
|
+
price_fx_portfolio = self.price * self.currency_fx_rate
|
|
220
|
+
if price_fx_portfolio and portfolio_value:
|
|
221
|
+
total_value = self.weighting * portfolio_value
|
|
222
|
+
self.shares = total_value / price_fx_portfolio
|
|
223
|
+
else:
|
|
224
|
+
self.shares = Decimal("0")
|
|
225
|
+
|
|
226
|
+
def set_shares(self, shares: Decimal, portfolio_value: Decimal):
|
|
227
|
+
if portfolio_value:
|
|
228
|
+
price_fx_portfolio = self.price * self.currency_fx_rate
|
|
229
|
+
self.shares = shares
|
|
230
|
+
total_value = shares * price_fx_portfolio
|
|
231
|
+
self.weighting = total_value / portfolio_value
|
|
232
|
+
else:
|
|
233
|
+
self.weighting = self.shares = Decimal("0")
|
|
234
|
+
|
|
235
|
+
def set_total_value_fx_portfolio(self, total_value_fx_portfolio: Decimal, portfolio_value: Decimal):
|
|
236
|
+
price_fx_portfolio = self.price * self.currency_fx_rate
|
|
237
|
+
if price_fx_portfolio and portfolio_value:
|
|
238
|
+
self.shares = total_value_fx_portfolio / price_fx_portfolio
|
|
239
|
+
self.weighting = total_value_fx_portfolio / portfolio_value
|
|
240
|
+
else:
|
|
241
|
+
self.weighting = self.shares = Decimal("0")
|
|
242
|
+
|
|
243
|
+
def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
|
|
244
|
+
warnings = []
|
|
245
|
+
|
|
246
|
+
# if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
|
|
247
|
+
if self.order_proposal and not self.portfolio.only_weighting:
|
|
248
|
+
shares = self.order_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
|
|
249
|
+
if shares != self.shares:
|
|
250
|
+
warnings.append(
|
|
251
|
+
f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
|
|
252
|
+
)
|
|
253
|
+
shares = round(shares) # ensure fractional shares are converted into integer
|
|
254
|
+
# we need to recompute the delta weight has we changed the number of shares
|
|
255
|
+
if shares != self.shares:
|
|
256
|
+
self.set_shares(shares, portfolio_total_asset_value)
|
|
257
|
+
if abs(self.weighting) < self.order_proposal.min_weighting:
|
|
258
|
+
warnings.append(
|
|
259
|
+
f"Weighting for order {self.underlying_instrument.computed_str} ({self.weighting}) is bellow the allowed Minimum Weighting ({self.order_proposal.min_weighting})"
|
|
260
|
+
)
|
|
261
|
+
self.set_weighting(Decimal("0"), portfolio_total_asset_value)
|
|
262
|
+
if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
|
|
263
|
+
warnings.append(
|
|
264
|
+
f"Total Value for order {self.underlying_instrument.computed_str} ({self.total_value_fx_portfolio}) is bellow the allowed Minimum Order Value ({self.order_proposal.min_order_value})"
|
|
265
|
+
)
|
|
266
|
+
self.set_weighting(Decimal("0"), portfolio_total_asset_value)
|
|
267
|
+
if not self.price:
|
|
268
|
+
warnings.append(f"No price for {self.underlying_instrument.computed_str}")
|
|
269
|
+
if (
|
|
270
|
+
not self.underlying_instrument.is_cash
|
|
271
|
+
and not self.underlying_instrument.is_cash_equivalent
|
|
272
|
+
and self._target_weight < -1e-8
|
|
273
|
+
): # any value below -1e8 will be considered zero
|
|
274
|
+
warnings.append(f"Negative target weight for {self.underlying_instrument.computed_str}")
|
|
275
|
+
self.desired_target_weight = self._target_weight
|
|
276
|
+
return warnings
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -72,7 +72,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
72
72
|
"""
|
|
73
73
|
A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
|
|
74
74
|
"""
|
|
75
|
-
|
|
75
|
+
max_iterations: int = (
|
|
76
76
|
5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
|
|
77
77
|
)
|
|
78
78
|
remaining_portfolios = set(self)
|
|
@@ -85,7 +85,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
85
85
|
dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
|
|
86
86
|
portfolio=p, dependency_portfolio__in=remaining_portfolios
|
|
87
87
|
) # get dependency portfolios
|
|
88
|
-
if iterator_counter >=
|
|
88
|
+
if iterator_counter >= max_iterations or (
|
|
89
89
|
not dependency_relationships.exists() and not bool(parent_portfolios)
|
|
90
90
|
): # if not dependency portfolio or parent portfolio that remained, then we yield
|
|
91
91
|
remaining_portfolios.remove(p)
|
|
@@ -949,7 +949,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
949
949
|
except IndexError:
|
|
950
950
|
position.portfolio_created = None
|
|
951
951
|
|
|
952
|
-
|
|
952
|
+
position.path = path
|
|
953
953
|
position.initial_shares = None
|
|
954
954
|
if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
|
|
955
955
|
position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
|
|
@@ -982,13 +982,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
982
982
|
)
|
|
983
983
|
if not to_date:
|
|
984
984
|
to_date = from_date
|
|
985
|
-
for
|
|
986
|
-
logger.info(f"Compute Look-Through for {self} at {
|
|
985
|
+
for val_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
986
|
+
logger.info(f"Compute Look-Through for {self} at {val_date}")
|
|
987
987
|
portfolio_total_asset_value = (
|
|
988
|
-
self.primary_portfolio.get_total_asset_under_management(
|
|
988
|
+
self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
|
|
989
989
|
)
|
|
990
990
|
self.builder.add(
|
|
991
|
-
list(self.primary_portfolio.get_lookthrough_positions(
|
|
991
|
+
list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
|
|
992
992
|
infer_underlying_quote_price=True,
|
|
993
993
|
)
|
|
994
994
|
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
@@ -61,6 +61,9 @@ class InstrumentPortfolioThroughModel(models.Model):
|
|
|
61
61
|
models.UniqueConstraint(fields=["instrument", "portfolio"], name="unique_portfolio_relationship"),
|
|
62
62
|
]
|
|
63
63
|
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return f"{self.instrument} - {self.portfolio}"
|
|
66
|
+
|
|
64
67
|
@classmethod
|
|
65
68
|
def get_portfolio(cls, instrument):
|
|
66
69
|
with suppress(InstrumentPortfolioThroughModel.DoesNotExist):
|
|
@@ -99,6 +102,9 @@ class PortfolioInstrumentPreferredClassificationThroughModel(models.Model):
|
|
|
99
102
|
related_name="preferred_classification_group_throughs",
|
|
100
103
|
)
|
|
101
104
|
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
return f"{self.portfolio} - {self.instrument}: ({self.classification})"
|
|
107
|
+
|
|
102
108
|
def save(self, *args, **kwargs) -> None:
|
|
103
109
|
if not self.classification_group and self.classification:
|
|
104
110
|
self.classification_group = self.classification.group
|
wbportfolio/models/products.py
CHANGED
|
@@ -89,6 +89,9 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
89
89
|
help_text=_("The Evaluation Frequency in RRULE format"),
|
|
90
90
|
)
|
|
91
91
|
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
return f"{self.portfolio.name} ({self.rebalancing_model})"
|
|
94
|
+
|
|
92
95
|
def save(self, *args, **kwargs):
|
|
93
96
|
if not self.activation_date:
|
|
94
97
|
try:
|
wbportfolio/models/roles.py
CHANGED
|
@@ -52,16 +52,10 @@ class PortfolioRole(models.Model):
|
|
|
52
52
|
return f"{self.role_type} {self.person.computed_str}"
|
|
53
53
|
|
|
54
54
|
def save(self, *args, **kwargs):
|
|
55
|
-
|
|
56
|
-
self.
|
|
57
|
-
|
|
58
|
-
self.
|
|
59
|
-
self.RoleType.ANALYST,
|
|
60
|
-
], self.default_error_messages["manager"].format(model="instrument")
|
|
61
|
-
|
|
62
|
-
assert (self.start and self.end and self.start < self.end) or (
|
|
63
|
-
not self.start or not self.end
|
|
64
|
-
), self.default_error_messages["start_end"]
|
|
55
|
+
if self.role_type in [self.RoleType.MANAGER, self.RoleType.RISK_MANAGER] and self.instrument:
|
|
56
|
+
raise ValueError(self.default_error_messages["manager"].format(model="instrument"))
|
|
57
|
+
if self.start and self.end and self.start > self.end:
|
|
58
|
+
raise ValueError(self.default_error_messages["start_end"])
|
|
65
59
|
|
|
66
60
|
super().save(*args, **kwargs)
|
|
67
61
|
|
|
@@ -259,9 +259,10 @@ class Claim(ReferenceIDMixin, WBModel):
|
|
|
259
259
|
return f"{self.reference_id} {self.product.name} ({self.bank} - {self.shares:,} shares - {self.date}) "
|
|
260
260
|
|
|
261
261
|
def save(self, *args, auto_match: bool = True, **kwargs):
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
262
|
+
if self.shares is None and self.nominal_amount is None:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"Either shares or nominal amount have to be provided. Shares={self.shares}, Nominal={self.nominal_amount}"
|
|
265
|
+
)
|
|
265
266
|
if self.product:
|
|
266
267
|
if self.shares is not None:
|
|
267
268
|
self.nominal_amount = self.shares * self.product.share_price
|
|
@@ -447,7 +448,7 @@ class Claim(ReferenceIDMixin, WBModel):
|
|
|
447
448
|
return self.can_approve()
|
|
448
449
|
|
|
449
450
|
def auto_match(self) -> Trade | None:
|
|
450
|
-
|
|
451
|
+
shares_epsilon = 1 # share
|
|
451
452
|
auto_match_trade = None
|
|
452
453
|
# Obvious filtering
|
|
453
454
|
trades = Trade.valid_customer_trade_objects.filter(
|
|
@@ -458,7 +459,7 @@ class Claim(ReferenceIDMixin, WBModel):
|
|
|
458
459
|
trades = trades.filter(underlying_instrument=self.product)
|
|
459
460
|
# Find trades by shares (or remaining to be claimed)
|
|
460
461
|
trades = trades.filter(
|
|
461
|
-
Q(diff_shares__lte=self.shares +
|
|
462
|
+
Q(diff_shares__lte=self.shares + shares_epsilon) & Q(diff_shares__gte=self.shares - shares_epsilon)
|
|
462
463
|
)
|
|
463
464
|
if trades.count() == 1:
|
|
464
465
|
auto_match_trade = trades.first()
|