wbportfolio 1.55.8__py2.py3-none-any.whl → 1.55.10rc0__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.
- wbportfolio/admin/portfolio.py +11 -5
- 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/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 +3 -3
- 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/utils.py +6 -2
- 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/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/models/asset.py +2 -1
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/orders/order_proposals.py +20 -2
- wbportfolio/models/orders/orders.py +6 -0
- wbportfolio/models/portfolio.py +27 -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/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/instrument_list_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 +1 -0
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +32 -0
- 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 +6 -4
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +5 -5
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/RECORD +63 -64
- wbportfolio/fdm/__init__.py +0 -0
- wbportfolio/fdm/tasks.py +0 -42
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.55.10rc0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
]
|
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,
|
|
@@ -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/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,
|
|
@@ -7,6 +7,7 @@ from typing import Any, TypeVar
|
|
|
7
7
|
|
|
8
8
|
from celery import shared_task
|
|
9
9
|
from django.core.exceptions import ValidationError
|
|
10
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
10
11
|
from django.db import DatabaseError, models
|
|
11
12
|
from django.db.models import (
|
|
12
13
|
F,
|
|
@@ -96,12 +97,22 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
96
97
|
min_order_value = models.IntegerField(
|
|
97
98
|
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
98
99
|
)
|
|
100
|
+
min_weighting = models.DecimalField(
|
|
101
|
+
max_digits=9,
|
|
102
|
+
decimal_places=Order.ORDER_WEIGHTING_PRECISION,
|
|
103
|
+
default=Decimal(0),
|
|
104
|
+
help_text="The minimum weight allowed for this order proposal ",
|
|
105
|
+
verbose_name="Minimum Weight",
|
|
106
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
107
|
+
)
|
|
108
|
+
|
|
99
109
|
total_cash_weight = models.DecimalField(
|
|
100
110
|
default=Decimal("0"),
|
|
101
111
|
decimal_places=4,
|
|
102
112
|
max_digits=5,
|
|
103
113
|
verbose_name="Total Cash Weight",
|
|
104
114
|
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%.",
|
|
115
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
105
116
|
)
|
|
106
117
|
total_effective_portfolio_contribution = models.DecimalField(
|
|
107
118
|
default=Decimal("1"),
|
|
@@ -136,6 +147,13 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
136
147
|
]
|
|
137
148
|
|
|
138
149
|
def save(self, *args, **kwargs):
|
|
150
|
+
# if the order proposal is created, we default these fields with the portfolio default value for automatic value assignement
|
|
151
|
+
if not self.id and not self.min_order_value:
|
|
152
|
+
self.min_order_value = self.portfolio.default_order_proposal_min_order_value
|
|
153
|
+
if not self.id and not self.min_weighting:
|
|
154
|
+
self.min_weighting = self.portfolio.default_order_proposal_min_weighting
|
|
155
|
+
if not self.id and not self.total_cash_weight:
|
|
156
|
+
self.total_cash_weight = self.portfolio.default_order_proposal_total_cash_weight
|
|
139
157
|
# if a order proposal is created before the existing earliest order proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
|
|
140
158
|
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
141
159
|
# we need to set the inception date as the first order proposal trade date (and thus, the first position date). We expect a NAV at 100 then
|
|
@@ -657,7 +675,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
657
675
|
except (ValidationError, DatabaseError) as e:
|
|
658
676
|
self.status = OrderProposal.Status.FAILED
|
|
659
677
|
if not silent_exception:
|
|
660
|
-
raise ValidationError(e)
|
|
678
|
+
raise ValidationError(e) from e
|
|
661
679
|
return
|
|
662
680
|
logger.info("Submitting order proposal ...")
|
|
663
681
|
self.submit()
|
|
@@ -1175,7 +1193,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
1175
1193
|
return warning
|
|
1176
1194
|
|
|
1177
1195
|
def can_cancelexecution(self):
|
|
1178
|
-
if
|
|
1196
|
+
if self.execution_status not in [ExecutionStatus.PENDING, ExecutionStatus.IN_DRAFT]:
|
|
1179
1197
|
return {"execution_status": "Execution can only be cancelled if it is not already executed"}
|
|
1180
1198
|
|
|
1181
1199
|
def update_execution_status(self):
|
|
@@ -87,6 +87,12 @@ class Order(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
87
87
|
self.shares = shares
|
|
88
88
|
if portfolio_total_asset_value:
|
|
89
89
|
self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
|
|
90
|
+
if abs(self.weighting) < self.order_proposal.min_weighting:
|
|
91
|
+
warnings.append(
|
|
92
|
+
f"Weighting for order {self.underlying_instrument.computed_str} ({self.weighting}) is bellow the allowed Minimum Weighting ({self.order_proposal.min_weighting})"
|
|
93
|
+
)
|
|
94
|
+
self.shares = Decimal("0")
|
|
95
|
+
self.weighting = Decimal("0")
|
|
90
96
|
if self.shares and abs(self.total_value_fx_portfolio) < self.order_proposal.min_order_value:
|
|
91
97
|
warnings.append(
|
|
92
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})"
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -9,6 +9,7 @@ import pandas as pd
|
|
|
9
9
|
from celery import shared_task
|
|
10
10
|
from django.contrib.postgres.fields import DateRangeField
|
|
11
11
|
from django.core.exceptions import ObjectDoesNotExist
|
|
12
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
12
13
|
from django.db import models
|
|
13
14
|
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
|
|
14
15
|
from django.db.models.signals import post_save
|
|
@@ -71,7 +72,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
71
72
|
"""
|
|
72
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.
|
|
73
74
|
"""
|
|
74
|
-
|
|
75
|
+
max_iterations: int = (
|
|
75
76
|
5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
|
|
76
77
|
)
|
|
77
78
|
remaining_portfolios = set(self)
|
|
@@ -84,7 +85,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
84
85
|
dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
|
|
85
86
|
portfolio=p, dependency_portfolio__in=remaining_portfolios
|
|
86
87
|
) # get dependency portfolios
|
|
87
|
-
if iterator_counter >=
|
|
88
|
+
if iterator_counter >= max_iterations or (
|
|
88
89
|
not dependency_relationships.exists() and not bool(parent_portfolios)
|
|
89
90
|
): # if not dependency portfolio or parent portfolio that remained, then we yield
|
|
90
91
|
remaining_portfolios.remove(p)
|
|
@@ -233,6 +234,25 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
233
234
|
blank=True,
|
|
234
235
|
)
|
|
235
236
|
|
|
237
|
+
# OMS default parameters. Used to seed order proposal default value upon creation
|
|
238
|
+
default_order_proposal_min_order_value = models.IntegerField(
|
|
239
|
+
default=0, verbose_name="Default Order Proposal Minimum Order Value"
|
|
240
|
+
)
|
|
241
|
+
default_order_proposal_min_weighting = models.DecimalField(
|
|
242
|
+
max_digits=9,
|
|
243
|
+
decimal_places=8,
|
|
244
|
+
default=Decimal(0),
|
|
245
|
+
verbose_name="Default Order Proposal Minimum Weight",
|
|
246
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
247
|
+
)
|
|
248
|
+
default_order_proposal_total_cash_weight = models.DecimalField(
|
|
249
|
+
default=Decimal("0"),
|
|
250
|
+
decimal_places=4,
|
|
251
|
+
max_digits=5,
|
|
252
|
+
verbose_name="Default Order Proposal Total Cash Weight",
|
|
253
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
254
|
+
)
|
|
255
|
+
|
|
236
256
|
objects = DefaultPortfolioManager()
|
|
237
257
|
tracked_objects = ActiveTrackedPortfolioManager()
|
|
238
258
|
|
|
@@ -929,7 +949,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
929
949
|
except IndexError:
|
|
930
950
|
position.portfolio_created = None
|
|
931
951
|
|
|
932
|
-
|
|
952
|
+
position.path = path
|
|
933
953
|
position.initial_shares = None
|
|
934
954
|
if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
|
|
935
955
|
position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
|
|
@@ -962,13 +982,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
962
982
|
)
|
|
963
983
|
if not to_date:
|
|
964
984
|
to_date = from_date
|
|
965
|
-
for
|
|
966
|
-
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}")
|
|
967
987
|
portfolio_total_asset_value = (
|
|
968
|
-
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
|
|
969
989
|
)
|
|
970
990
|
self.builder.add(
|
|
971
|
-
list(self.primary_portfolio.get_lookthrough_positions(
|
|
991
|
+
list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
|
|
972
992
|
infer_underlying_quote_price=True,
|
|
973
993
|
)
|
|
974
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()
|
wbportfolio/pms/typing.py
CHANGED
|
@@ -269,7 +269,7 @@ class TradeBatch:
|
|
|
269
269
|
|
|
270
270
|
def convert_to_portfolio(self, use_effective: bool = False, *extra_positions):
|
|
271
271
|
positions = []
|
|
272
|
-
for
|
|
272
|
+
for trade in self.trades_map.values():
|
|
273
273
|
positions.append(
|
|
274
274
|
Position(
|
|
275
275
|
underlying_instrument=trade.underlying_instrument,
|
|
@@ -108,11 +108,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
|
|
|
108
108
|
return df.any()
|
|
109
109
|
else:
|
|
110
110
|
if missing_exchanges.exists():
|
|
111
|
-
|
|
112
|
-
self,
|
|
113
|
-
"_validation_errors",
|
|
114
|
-
f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
|
|
115
|
-
)
|
|
111
|
+
self._validation_errors = f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}"
|
|
116
112
|
return df.all()
|
|
117
113
|
return False
|
|
118
114
|
|
|
@@ -44,7 +44,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
|
|
|
44
44
|
return RuleBackendSerializer
|
|
45
45
|
|
|
46
46
|
def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
|
|
47
|
-
for instrument_id
|
|
47
|
+
for instrument_id in portfolio.positions_map.keys():
|
|
48
48
|
instrument = Instrument.objects.get(id=instrument_id)
|
|
49
49
|
if (
|
|
50
50
|
controversies := Controversy.objects.filter(
|
|
@@ -64,7 +64,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
|
|
|
64
64
|
return RuleBackendSerializer
|
|
65
65
|
|
|
66
66
|
def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
|
|
67
|
-
for instrument_id
|
|
67
|
+
for instrument_id in portfolio.positions_map.keys():
|
|
68
68
|
instrument = Instrument.objects.get(id=instrument_id)
|
|
69
69
|
relationships = self.instruments_relationship.filter(instrument=instrument, validated=True)
|
|
70
70
|
|
|
@@ -92,7 +92,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
|
|
|
92
92
|
date=weekday, net_value=500, calculated=False, instrument=benchmark
|
|
93
93
|
)
|
|
94
94
|
RelatedInstrumentThroughModel.objects.create(instrument=product, related_instrument=benchmark, is_primary=True)
|
|
95
|
-
|
|
95
|
+
stop_loss_instrument_backend.dynamic_benchmark_type = "PRIMARY_BENCHMARK"
|
|
96
96
|
|
|
97
97
|
res = list(stop_loss_instrument_backend.check_rule())
|
|
98
98
|
assert len(res) == 0
|
|
@@ -105,7 +105,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
|
|
|
105
105
|
assert len(res) == 1
|
|
106
106
|
assert res[0].breached_object.id == product.id
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
stop_loss_instrument_backend.static_benchmark = benchmark
|
|
109
109
|
res = list(stop_loss_instrument_backend.check_rule())
|
|
110
110
|
assert len(res) == 1
|
|
111
111
|
assert res[0].breached_object.id == product.id
|
|
@@ -119,7 +119,7 @@ class TestStopLossPortfolioRuleModel(PortfolioTestMixin):
|
|
|
119
119
|
res = list(stop_loss_portfolio_backend.check_rule())
|
|
120
120
|
assert len(res) == 0
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
stop_loss_portfolio_backend.static_benchmark = benchmark
|
|
123
123
|
res = list(stop_loss_portfolio_backend.check_rule())
|
|
124
124
|
assert len(res) == 1
|
|
125
125
|
assert res[0].breached_object.id == instrument.id
|
|
@@ -49,8 +49,8 @@ class ClaimAPIModelSerializer(serializers.ModelSerializer):
|
|
|
49
49
|
if "isin" in request.data:
|
|
50
50
|
try:
|
|
51
51
|
data["product"] = Product.objects.get(isin=request.data["isin"])
|
|
52
|
-
except Product.DoesNotExist:
|
|
53
|
-
raise ValidationError({"isin": "A product with this ISIN does not exist."})
|
|
52
|
+
except Product.DoesNotExist as e:
|
|
53
|
+
raise ValidationError({"isin": "A product with this ISIN does not exist."}) from e
|
|
54
54
|
if trade := data.get("trade", None):
|
|
55
55
|
if not trade.is_claimable:
|
|
56
56
|
raise ValidationError({"trade": "Only a claimable trade can be selected"})
|
wbportfolio/tasks.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from contextlib import suppress
|
|
2
2
|
from datetime import date, timedelta
|
|
3
3
|
|
|
4
|
+
from celery import shared_task
|
|
4
5
|
from django.db.models import ProtectedError, Q
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
from wbfdm.models import Controversy, Instrument
|
|
5
8
|
|
|
6
|
-
from wbportfolio.models import Portfolio, Trade
|
|
7
|
-
from wbportfolio.models.products import Product
|
|
8
|
-
|
|
9
|
-
from .fdm.tasks import * # noqa
|
|
9
|
+
from wbportfolio.models import AssetPosition, Portfolio, Product, Trade
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@shared_task(queue="portfolio")
|
|
@@ -49,3 +49,41 @@ def update_preferred_classification_per_instrument_and_portfolio_as_task():
|
|
|
49
49
|
# - propagate (or update) t-2 asset positions into t-1
|
|
50
50
|
# - Synchronize wbportfolio at t-1
|
|
51
51
|
# - Compute Instrument Price estimate at t-1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@shared_task(queue="portfolio")
|
|
55
|
+
def synchronize_portfolio_controversies():
|
|
56
|
+
active_portfolios = Portfolio.objects.filter_active_and_tracked()
|
|
57
|
+
qs = (
|
|
58
|
+
AssetPosition.objects.filter(portfolio__in=active_portfolios)
|
|
59
|
+
.values("underlying_instrument")
|
|
60
|
+
.distinct("underlying_instrument")
|
|
61
|
+
)
|
|
62
|
+
objs = {}
|
|
63
|
+
securities = Instrument.objects.filter(id__in=qs.values("underlying_instrument"))
|
|
64
|
+
securities_mapping = {security.id: security.get_root() for security in securities}
|
|
65
|
+
for controversy in securities.dl.esg_controversies():
|
|
66
|
+
instrument = securities_mapping[controversy["instrument_id"]]
|
|
67
|
+
obj = Controversy.dict_to_model(controversy, instrument)
|
|
68
|
+
objs[obj.external_id] = obj
|
|
69
|
+
|
|
70
|
+
Controversy.objects.bulk_create(
|
|
71
|
+
objs.values(),
|
|
72
|
+
update_fields=[
|
|
73
|
+
"instrument",
|
|
74
|
+
"headline",
|
|
75
|
+
"description",
|
|
76
|
+
"source",
|
|
77
|
+
"direct_involvement",
|
|
78
|
+
"company_response",
|
|
79
|
+
"review",
|
|
80
|
+
"initiated",
|
|
81
|
+
"flag",
|
|
82
|
+
"status",
|
|
83
|
+
"type",
|
|
84
|
+
"severity",
|
|
85
|
+
],
|
|
86
|
+
unique_fields=["external_id"],
|
|
87
|
+
update_conflicts=True,
|
|
88
|
+
batch_size=10000,
|
|
89
|
+
)
|
|
@@ -695,6 +695,38 @@ class TestOrderProposal:
|
|
|
695
695
|
assert order.shares == Decimal(0)
|
|
696
696
|
assert order.weighting == Decimal(0)
|
|
697
697
|
|
|
698
|
+
def test_order_submit_bellow_minimum_weighting(self, order_factory, order_proposal):
|
|
699
|
+
o1 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.8"))
|
|
700
|
+
o2 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.2"))
|
|
701
|
+
order_proposal.submit()
|
|
702
|
+
order_proposal.save()
|
|
703
|
+
|
|
704
|
+
o1.refresh_from_db()
|
|
705
|
+
o2.refresh_from_db()
|
|
706
|
+
assert o1.weighting == Decimal("0.8")
|
|
707
|
+
assert o2.weighting == Decimal("0.2")
|
|
708
|
+
|
|
709
|
+
order_proposal.min_weighting = Decimal("0.21")
|
|
710
|
+
order_proposal.backtodraft()
|
|
711
|
+
order_proposal.submit()
|
|
712
|
+
order_proposal.save()
|
|
713
|
+
|
|
714
|
+
o1.refresh_from_db()
|
|
715
|
+
o2.refresh_from_db()
|
|
716
|
+
assert o1.weighting == Decimal("0.8")
|
|
717
|
+
assert o2.weighting == Decimal("0")
|
|
718
|
+
|
|
719
|
+
order_proposal.approve()
|
|
720
|
+
order_proposal.apply()
|
|
721
|
+
order_proposal.save()
|
|
722
|
+
|
|
723
|
+
assert order_proposal.portfolio.assets.get(
|
|
724
|
+
date=order_proposal.trade_date, underlying_quote=o1.underlying_instrument
|
|
725
|
+
).weighting == Decimal("0.8")
|
|
726
|
+
assert order_proposal.portfolio.assets.get(
|
|
727
|
+
date=order_proposal.trade_date, underlying_quote=order_proposal.cash_component
|
|
728
|
+
).weighting == Decimal("0.2")
|
|
729
|
+
|
|
698
730
|
def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
|
|
699
731
|
order1 = order_factory.create(
|
|
700
732
|
order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")
|