wbportfolio 1.54.13__py2.py3-none-any.whl → 1.54.15__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/__init__.py +2 -0
- wbportfolio/admin/orders/__init__.py +2 -0
- wbportfolio/admin/orders/order_proposals.py +14 -0
- wbportfolio/admin/orders/orders.py +30 -0
- wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -1
- wbportfolio/admin/transactions/trades.py +2 -17
- wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
- wbportfolio/factories/__init__.py +2 -1
- wbportfolio/factories/orders/__init__.py +2 -0
- wbportfolio/factories/orders/order_proposals.py +17 -0
- wbportfolio/factories/orders/orders.py +21 -0
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/factories/trades.py +2 -13
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/orders.py +11 -0
- wbportfolio/import_export/handlers/trade.py +20 -20
- wbportfolio/import_export/resources/trades.py +2 -2
- wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
- wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
- wbportfolio/models/__init__.py +2 -0
- wbportfolio/models/orders/__init__.py +2 -0
- wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +289 -245
- wbportfolio/models/orders/orders.py +243 -0
- wbportfolio/models/portfolio.py +17 -20
- wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
- wbportfolio/models/transactions/__init__.py +0 -2
- wbportfolio/models/transactions/trades.py +10 -450
- wbportfolio/pms/analytics/portfolio.py +10 -6
- wbportfolio/pms/analytics/utils.py +9 -0
- wbportfolio/pms/trading/handler.py +6 -4
- wbportfolio/pms/typing.py +18 -7
- wbportfolio/rebalancing/decorators.py +1 -1
- wbportfolio/rebalancing/models/composite.py +3 -7
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
- wbportfolio/serializers/__init__.py +1 -0
- wbportfolio/serializers/orders/__init__.py +2 -0
- wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +23 -15
- wbportfolio/serializers/orders/orders.py +187 -0
- wbportfolio/serializers/portfolios.py +7 -7
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/serializers/transactions/__init__.py +1 -5
- wbportfolio/serializers/transactions/trades.py +1 -182
- wbportfolio/tests/conftest.py +4 -2
- wbportfolio/tests/models/orders/__init__.py +0 -0
- wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +218 -246
- wbportfolio/tests/models/test_portfolios.py +11 -10
- wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
- wbportfolio/tests/models/transactions/test_trades.py +0 -20
- wbportfolio/tests/rebalancing/test_models.py +24 -28
- wbportfolio/tests/signals.py +10 -10
- wbportfolio/tests/tests.py +1 -1
- wbportfolio/urls.py +7 -7
- wbportfolio/viewsets/__init__.py +2 -0
- wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
- wbportfolio/viewsets/configs/buttons/trades.py +0 -8
- wbportfolio/viewsets/configs/display/__init__.py +0 -2
- wbportfolio/viewsets/configs/display/portfolios.py +5 -5
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/display/trades.py +1 -225
- wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
- wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
- wbportfolio/viewsets/orders/__init__.py +6 -0
- wbportfolio/viewsets/orders/configs/__init__.py +4 -0
- wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
- wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +22 -21
- wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
- wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
- wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
- wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
- wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
- wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
- wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
- wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -45
- wbportfolio/viewsets/orders/orders.py +219 -0
- wbportfolio/viewsets/portfolios.py +12 -12
- wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
- wbportfolio/viewsets/transactions/__init__.py +1 -7
- wbportfolio/viewsets/transactions/trades.py +1 -199
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/RECORD +85 -58
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from django.shortcuts import get_object_or_404
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
from wbcore.contrib.color.enums import WBColor
|
|
6
|
+
from wbcore.enums import Unit
|
|
7
|
+
from wbcore.metadata.configs import display as dp
|
|
8
|
+
from wbcore.metadata.configs.display.instance_display.shortcuts import (
|
|
9
|
+
Display,
|
|
10
|
+
create_simple_display,
|
|
11
|
+
)
|
|
12
|
+
from wbcore.metadata.configs.display.instance_display.utils import repeat_field
|
|
13
|
+
from wbcore.metadata.configs.display.view_config import DisplayViewConfig
|
|
14
|
+
|
|
15
|
+
from wbportfolio.models import Order, OrderProposal
|
|
16
|
+
|
|
17
|
+
ORDER_STATUS_LEGENDS = dp.Legend(
|
|
18
|
+
key="has_warnings",
|
|
19
|
+
items=[
|
|
20
|
+
dp.LegendItem(icon=WBColor.YELLOW_DARK.value, label=_("Warning"), value=True),
|
|
21
|
+
],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ORDER_STATUS_FORMATTING = dp.Formatting(
|
|
25
|
+
column="has_warnings",
|
|
26
|
+
formatting_rules=[
|
|
27
|
+
dp.FormattingRule(
|
|
28
|
+
style={"backgroundColor": WBColor.YELLOW_DARK.value},
|
|
29
|
+
condition=("==", True),
|
|
30
|
+
)
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
ORDER_TYPE_FORMATTING_RULES = [
|
|
34
|
+
dp.FormattingRule(
|
|
35
|
+
style={"color": WBColor.RED_DARK.value, "fontWeight": "bold"},
|
|
36
|
+
condition=("==", Order.Type.SELL.name),
|
|
37
|
+
),
|
|
38
|
+
dp.FormattingRule(
|
|
39
|
+
style={"color": WBColor.RED.value, "fontWeight": "bold"},
|
|
40
|
+
condition=("==", Order.Type.DECREASE.name),
|
|
41
|
+
),
|
|
42
|
+
dp.FormattingRule(
|
|
43
|
+
style={"color": WBColor.GREEN.value, "fontWeight": "bold"},
|
|
44
|
+
condition=("==", Order.Type.INCREASE.name),
|
|
45
|
+
),
|
|
46
|
+
dp.FormattingRule(
|
|
47
|
+
style={"color": WBColor.GREEN_DARK.value, "fontWeight": "bold"},
|
|
48
|
+
condition=("==", Order.Type.BUY.name),
|
|
49
|
+
),
|
|
50
|
+
dp.FormattingRule(
|
|
51
|
+
style={"color": WBColor.GREY.value, "fontWeight": "bold"},
|
|
52
|
+
condition=("==", Order.Type.NO_CHANGE.name),
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
VALUE_FORMATTING_RULES = [
|
|
57
|
+
dp.FormattingRule(
|
|
58
|
+
style={"color": WBColor.RED_DARK.value, "fontWeight": "bold"},
|
|
59
|
+
condition=("<", 0),
|
|
60
|
+
),
|
|
61
|
+
dp.FormattingRule(
|
|
62
|
+
style={"color": WBColor.GREEN_DARK.value, "fontWeight": "bold"},
|
|
63
|
+
condition=(">", 0),
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OrderOrderProposalDisplayConfig(DisplayViewConfig):
|
|
69
|
+
def get_list_display(self) -> Optional[dp.ListDisplay]:
|
|
70
|
+
order_proposal = get_object_or_404(OrderProposal, pk=self.view.kwargs.get("order_proposal_id", None))
|
|
71
|
+
fields = [
|
|
72
|
+
dp.Field(
|
|
73
|
+
label="Instrument",
|
|
74
|
+
open_by_default=True,
|
|
75
|
+
key=None,
|
|
76
|
+
children=[
|
|
77
|
+
dp.Field(key="underlying_instrument", label="Name", width=Unit.PIXEL(250)),
|
|
78
|
+
dp.Field(key="underlying_instrument_isin", label="ISIN", width=Unit.PIXEL(125)),
|
|
79
|
+
dp.Field(key="underlying_instrument_ticker", label="Ticker", width=Unit.PIXEL(100)),
|
|
80
|
+
dp.Field(
|
|
81
|
+
key="underlying_instrument_refinitiv_identifier_code", label="RIC", width=Unit.PIXEL(100)
|
|
82
|
+
),
|
|
83
|
+
dp.Field(key="underlying_instrument_instrument_type", label="Asset Class", width=Unit.PIXEL(125)),
|
|
84
|
+
],
|
|
85
|
+
),
|
|
86
|
+
dp.Field(
|
|
87
|
+
label="Weight",
|
|
88
|
+
open_by_default=False,
|
|
89
|
+
key=None,
|
|
90
|
+
children=[
|
|
91
|
+
dp.Field(key="effective_weight", label="Effective Weight", show="open", width=Unit.PIXEL(150)),
|
|
92
|
+
dp.Field(key="target_weight", label="Target Weight", show="open", width=Unit.PIXEL(150)),
|
|
93
|
+
dp.Field(
|
|
94
|
+
key="weighting",
|
|
95
|
+
label="Delta Weight",
|
|
96
|
+
formatting_rules=VALUE_FORMATTING_RULES,
|
|
97
|
+
width=Unit.PIXEL(150),
|
|
98
|
+
),
|
|
99
|
+
],
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
if not order_proposal.portfolio.only_weighting:
|
|
103
|
+
fields.append(
|
|
104
|
+
dp.Field(
|
|
105
|
+
label="Shares",
|
|
106
|
+
open_by_default=False,
|
|
107
|
+
key=None,
|
|
108
|
+
children=[
|
|
109
|
+
dp.Field(key="effective_shares", label="Effective Shares", show="open", width=Unit.PIXEL(150)),
|
|
110
|
+
dp.Field(key="target_shares", label="Target Shares", show="open", width=Unit.PIXEL(150)),
|
|
111
|
+
dp.Field(
|
|
112
|
+
key="shares",
|
|
113
|
+
label="Shares",
|
|
114
|
+
formatting_rules=VALUE_FORMATTING_RULES,
|
|
115
|
+
width=Unit.PIXEL(150),
|
|
116
|
+
),
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
fields.append(
|
|
121
|
+
dp.Field(
|
|
122
|
+
label="Total Value",
|
|
123
|
+
open_by_default=False,
|
|
124
|
+
key=None,
|
|
125
|
+
children=[
|
|
126
|
+
dp.Field(
|
|
127
|
+
key="effective_total_value_fx_portfolio",
|
|
128
|
+
label="Effective Total Value",
|
|
129
|
+
show="open",
|
|
130
|
+
width=Unit.PIXEL(150),
|
|
131
|
+
),
|
|
132
|
+
dp.Field(
|
|
133
|
+
key="target_total_value_fx_portfolio",
|
|
134
|
+
label="Target Total Value",
|
|
135
|
+
show="open",
|
|
136
|
+
width=Unit.PIXEL(150),
|
|
137
|
+
),
|
|
138
|
+
dp.Field(
|
|
139
|
+
key="total_value_fx_portfolio",
|
|
140
|
+
label="Total Value",
|
|
141
|
+
formatting_rules=VALUE_FORMATTING_RULES,
|
|
142
|
+
width=Unit.PIXEL(150),
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
fields.append(
|
|
148
|
+
dp.Field(
|
|
149
|
+
label="Information",
|
|
150
|
+
open_by_default=False,
|
|
151
|
+
key=None,
|
|
152
|
+
children=[
|
|
153
|
+
dp.Field(
|
|
154
|
+
key="order_type",
|
|
155
|
+
label="Direction",
|
|
156
|
+
formatting_rules=ORDER_TYPE_FORMATTING_RULES,
|
|
157
|
+
width=Unit.PIXEL(125),
|
|
158
|
+
),
|
|
159
|
+
dp.Field(key="comment", label="Comment", width=Unit.PIXEL(250)),
|
|
160
|
+
dp.Field(key="order", label="Order", show="open", width=Unit.PIXEL(100)),
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return dp.ListDisplay(
|
|
165
|
+
fields=fields,
|
|
166
|
+
legends=[ORDER_STATUS_LEGENDS],
|
|
167
|
+
formatting=[ORDER_STATUS_FORMATTING],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def get_instance_display(self) -> Display:
|
|
171
|
+
order_proposal = get_object_or_404(OrderProposal, pk=self.view.kwargs.get("order_proposal_id", None))
|
|
172
|
+
|
|
173
|
+
fields = [
|
|
174
|
+
["company", "security", "underlying_instrument"],
|
|
175
|
+
["effective_weight", "target_weight", "weighting"],
|
|
176
|
+
]
|
|
177
|
+
if not order_proposal.portfolio.only_weighting:
|
|
178
|
+
fields.append(["effective_shares", "target_shares", "shares"])
|
|
179
|
+
fields.append([repeat_field(3, "comment")])
|
|
180
|
+
return create_simple_display(fields)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from django.shortcuts import get_object_or_404
|
|
2
|
+
from rest_framework.reverse import reverse
|
|
3
|
+
from wbcore.metadata.configs.endpoints import EndpointViewConfig
|
|
4
|
+
|
|
5
|
+
from wbportfolio.models import OrderProposal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OrderProposalEndpointConfig(EndpointViewConfig):
|
|
9
|
+
def get_delete_endpoint(self, **kwargs):
|
|
10
|
+
if pk := self.view.kwargs.get("pk", None):
|
|
11
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
12
|
+
if order_proposal.status == OrderProposal.Status.DRAFT:
|
|
13
|
+
return super().get_endpoint()
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OrderProposalPortfolioEndpointConfig(OrderProposalEndpointConfig):
|
|
18
|
+
def get_endpoint(self, **kwargs):
|
|
19
|
+
return reverse(
|
|
20
|
+
"wbportfolio:portfolio-orderproposal-list", args=[self.view.kwargs["portfolio_id"]], request=self.request
|
|
21
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
|
|
3
|
+
from rest_framework.reverse import reverse
|
|
4
|
+
from wbcore.metadata.configs.endpoints import EndpointViewConfig
|
|
5
|
+
|
|
6
|
+
from wbportfolio.models import OrderProposal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderOrderProposalEndpointConfig(EndpointViewConfig):
|
|
10
|
+
def get_endpoint(self, **kwargs):
|
|
11
|
+
if order_proposal_id := self.view.kwargs.get("order_proposal_id", None):
|
|
12
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
13
|
+
if order_proposal.status == OrderProposal.Status.DRAFT:
|
|
14
|
+
return reverse(
|
|
15
|
+
"wbportfolio:orderproposal-order-list",
|
|
16
|
+
args=[self.view.kwargs["order_proposal_id"]],
|
|
17
|
+
request=self.request,
|
|
18
|
+
)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
def get_delete_endpoint(self, **kwargs):
|
|
22
|
+
with suppress(AttributeError, AssertionError):
|
|
23
|
+
order = self.view.get_object()
|
|
24
|
+
if order._effective_weight: # we make sure order with a valid effective position cannot be deleted
|
|
25
|
+
return None
|
|
26
|
+
return super().get_delete_endpoint(**kwargs)
|
|
File without changes
|
|
File without changes
|
|
@@ -19,49 +19,49 @@ from wbcore.metadata.configs.display.instance_display import (
|
|
|
19
19
|
from wbcore.permissions.permissions import InternalUserPermissionMixin
|
|
20
20
|
from wbcore.utils.views import CloneMixin
|
|
21
21
|
|
|
22
|
-
from wbportfolio.models import AssetPosition,
|
|
23
|
-
from wbportfolio.models.
|
|
22
|
+
from wbportfolio.models import AssetPosition, OrderProposal
|
|
23
|
+
from wbportfolio.models.orders.order_proposals import (
|
|
24
24
|
replay_as_task,
|
|
25
25
|
)
|
|
26
26
|
from wbportfolio.serializers import (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
OrderProposalModelSerializer,
|
|
28
|
+
OrderProposalRepresentationSerializer,
|
|
29
|
+
ReadOnlyOrderProposalModelSerializer,
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
from ..configs import (
|
|
33
|
-
TradeProposalButtonConfig,
|
|
34
|
-
TradeProposalDisplayConfig,
|
|
35
|
-
TradeProposalEndpointConfig,
|
|
36
|
-
TradeProposalPortfolioEndpointConfig,
|
|
37
|
-
)
|
|
38
32
|
from ..mixins import UserPortfolioRequestPermissionMixin
|
|
33
|
+
from .configs import (
|
|
34
|
+
OrderProposalButtonConfig,
|
|
35
|
+
OrderProposalDisplayConfig,
|
|
36
|
+
OrderProposalEndpointConfig,
|
|
37
|
+
OrderProposalPortfolioEndpointConfig,
|
|
38
|
+
)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
class
|
|
41
|
+
class OrderProposalRepresentationViewSet(InternalUserPermissionMixin, viewsets.RepresentationViewSet):
|
|
42
42
|
IDENTIFIER = "wbportfolio:trade"
|
|
43
|
-
queryset =
|
|
44
|
-
serializer_class =
|
|
43
|
+
queryset = OrderProposal.objects.all()
|
|
44
|
+
serializer_class = OrderProposalRepresentationSerializer
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
class
|
|
47
|
+
class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserPermissionMixin, viewsets.ModelViewSet):
|
|
48
48
|
ordering_fields = ("trade_date",)
|
|
49
49
|
ordering = ("-trade_date",)
|
|
50
50
|
search_fields = ("comment",)
|
|
51
51
|
filterset_fields = {"trade_date": ["exact", "gte", "lte"], "status": ["exact"]}
|
|
52
52
|
|
|
53
|
-
queryset =
|
|
54
|
-
serializer_class =
|
|
55
|
-
display_config_class =
|
|
56
|
-
button_config_class =
|
|
57
|
-
endpoint_config_class =
|
|
53
|
+
queryset = OrderProposal.objects.select_related("rebalancing_model", "portfolio")
|
|
54
|
+
serializer_class = OrderProposalModelSerializer
|
|
55
|
+
display_config_class = OrderProposalDisplayConfig
|
|
56
|
+
button_config_class = OrderProposalButtonConfig
|
|
57
|
+
endpoint_config_class = OrderProposalEndpointConfig
|
|
58
58
|
|
|
59
59
|
def get_serializer_class(self):
|
|
60
60
|
if self.new_mode or (
|
|
61
|
-
"pk" in self.kwargs and (obj := self.get_object()) and obj.status ==
|
|
61
|
+
"pk" in self.kwargs and (obj := self.get_object()) and obj.status == OrderProposal.Status.DRAFT
|
|
62
62
|
):
|
|
63
|
-
return
|
|
64
|
-
return
|
|
63
|
+
return OrderProposalModelSerializer
|
|
64
|
+
return ReadOnlyOrderProposalModelSerializer
|
|
65
65
|
|
|
66
66
|
# 2 methods to parametrize the clone button functionality
|
|
67
67
|
def get_clone_button_serializer_class(self, instance):
|
|
@@ -82,13 +82,13 @@ class TradeProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
82
82
|
)
|
|
83
83
|
|
|
84
84
|
def add_messages(self, request, instance=None, **kwargs):
|
|
85
|
-
if instance and instance.status ==
|
|
85
|
+
if instance and instance.status == OrderProposal.Status.SUBMIT:
|
|
86
86
|
if not instance.portfolio.is_manageable:
|
|
87
|
-
info(request, "This
|
|
87
|
+
info(request, "This order proposal cannot be approved the portfolio is considered unmanaged.")
|
|
88
88
|
if instance.has_non_successful_checks:
|
|
89
89
|
warning(
|
|
90
90
|
request,
|
|
91
|
-
"This
|
|
91
|
+
"This order proposal cannot be approved because there is unsuccessful pre-trade checks. Please rectify accordingly and resubmit a valid order proposal",
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
@classmethod
|
|
@@ -97,40 +97,41 @@ class TradeProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
97
97
|
|
|
98
98
|
@action(detail=True, methods=["PATCH"])
|
|
99
99
|
def reset(self, request, pk=None):
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
100
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
101
|
+
if order_proposal.status == OrderProposal.Status.DRAFT:
|
|
102
|
+
order_proposal.orders.all().update(weighting=0)
|
|
103
|
+
order_proposal.reset_orders()
|
|
103
104
|
return Response({"send": True})
|
|
104
|
-
return Response({"status": "
|
|
105
|
+
return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
105
106
|
|
|
106
107
|
@action(detail=True, methods=["PATCH"])
|
|
107
108
|
def normalize(self, request, pk=None):
|
|
108
|
-
|
|
109
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
109
110
|
total_cash_weight = Decimal(request.data.get("total_cash_weight", Decimal("0.0")))
|
|
110
|
-
if
|
|
111
|
-
|
|
111
|
+
if order_proposal.status == OrderProposal.Status.DRAFT:
|
|
112
|
+
order_proposal.normalize_orders(total_target_weight=Decimal("1.0") - total_cash_weight)
|
|
112
113
|
return Response({"send": True})
|
|
113
|
-
return Response({"status": "
|
|
114
|
+
return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
114
115
|
|
|
115
116
|
@action(detail=True, methods=["PATCH"])
|
|
116
117
|
def replay(self, request, pk=None):
|
|
117
|
-
|
|
118
|
-
if
|
|
119
|
-
replay_as_task.delay(
|
|
118
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
119
|
+
if order_proposal.portfolio.is_manageable:
|
|
120
|
+
replay_as_task.delay(order_proposal.id, user_id=self.request.user.id)
|
|
120
121
|
return Response({"send": True})
|
|
121
|
-
return Response({"status": "
|
|
122
|
+
return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
122
123
|
|
|
123
124
|
@action(detail=True, methods=["PATCH"])
|
|
124
125
|
def deleteall(self, request, pk=None):
|
|
125
|
-
|
|
126
|
-
if
|
|
127
|
-
|
|
126
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
127
|
+
if order_proposal.status == OrderProposal.Status.DRAFT:
|
|
128
|
+
order_proposal.orders.all().delete()
|
|
128
129
|
return Response({"send": True})
|
|
129
|
-
return Response({"status": "
|
|
130
|
+
return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
130
131
|
|
|
131
132
|
|
|
132
|
-
class
|
|
133
|
-
endpoint_config_class =
|
|
133
|
+
class OrderProposalPortfolioModelViewSet(UserPortfolioRequestPermissionMixin, OrderProposalModelViewSet):
|
|
134
|
+
endpoint_config_class = OrderProposalPortfolioEndpointConfig
|
|
134
135
|
|
|
135
136
|
@cached_property
|
|
136
137
|
def default_trade_date(self) -> date | None:
|
|
@@ -138,4 +139,4 @@ class TradeProposalPortfolioModelViewSet(UserPortfolioRequestPermissionMixin, Tr
|
|
|
138
139
|
return (self.portfolio.assets.latest("date").date + BDay(1)).date()
|
|
139
140
|
|
|
140
141
|
def get_queryset(self):
|
|
141
|
-
return
|
|
142
|
+
return OrderProposal.objects.filter(portfolio=self.kwargs["portfolio_id"])
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
from django.contrib.messages import error, warning
|
|
4
|
+
from django.db.models import (
|
|
5
|
+
Case,
|
|
6
|
+
F,
|
|
7
|
+
Sum,
|
|
8
|
+
Value,
|
|
9
|
+
When,
|
|
10
|
+
)
|
|
11
|
+
from django.shortcuts import get_object_or_404
|
|
12
|
+
from django.utils.functional import cached_property
|
|
13
|
+
from wbcore import viewsets
|
|
14
|
+
from wbcore.permissions.permissions import InternalUserPermissionMixin
|
|
15
|
+
from wbcore.utils.strings import format_number
|
|
16
|
+
from wbcore.viewsets.mixins import OrderableMixin
|
|
17
|
+
|
|
18
|
+
from wbportfolio.import_export.resources.trades import OrderProposalTradeResource
|
|
19
|
+
from wbportfolio.models import Order, OrderProposal
|
|
20
|
+
from wbportfolio.serializers import (
|
|
21
|
+
OrderOrderProposalListModelSerializer,
|
|
22
|
+
OrderOrderProposalModelSerializer,
|
|
23
|
+
ReadOnlyOrderOrderProposalModelSerializer,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ...filters.orders import OrderFilterSet
|
|
27
|
+
from ..mixins import UserPortfolioRequestPermissionMixin
|
|
28
|
+
from .configs import (
|
|
29
|
+
OrderOrderProposalButtonConfig,
|
|
30
|
+
OrderOrderProposalDisplayConfig,
|
|
31
|
+
OrderOrderProposalEndpointConfig,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OrderOrderProposalModelViewSet(
|
|
36
|
+
UserPortfolioRequestPermissionMixin, InternalUserPermissionMixin, OrderableMixin, viewsets.ModelViewSet
|
|
37
|
+
):
|
|
38
|
+
IMPORT_ALLOWED = True
|
|
39
|
+
ordering = (
|
|
40
|
+
"order_proposal",
|
|
41
|
+
"order",
|
|
42
|
+
)
|
|
43
|
+
ordering_fields = (
|
|
44
|
+
"underlying_instrument__name",
|
|
45
|
+
"underlying_instrument_isin",
|
|
46
|
+
"underlying_instrument_ticker",
|
|
47
|
+
"underlying_instrument_refinitiv_identifier_code",
|
|
48
|
+
"underlying_instrument_instrument_type",
|
|
49
|
+
"target_weight",
|
|
50
|
+
"effective_weight",
|
|
51
|
+
"effective_shares",
|
|
52
|
+
"target_shares",
|
|
53
|
+
"shares",
|
|
54
|
+
"weighting",
|
|
55
|
+
)
|
|
56
|
+
IDENTIFIER = "wbportfolio:orderproposal"
|
|
57
|
+
search_fields = ("underlying_instrument__name",)
|
|
58
|
+
queryset = Order.objects.none()
|
|
59
|
+
filterset_class = OrderFilterSet
|
|
60
|
+
|
|
61
|
+
display_config_class = OrderOrderProposalDisplayConfig
|
|
62
|
+
endpoint_config_class = OrderOrderProposalEndpointConfig
|
|
63
|
+
serializer_class = OrderOrderProposalModelSerializer
|
|
64
|
+
button_config_class = OrderOrderProposalButtonConfig
|
|
65
|
+
|
|
66
|
+
@cached_property
|
|
67
|
+
def order_proposal(self):
|
|
68
|
+
return get_object_or_404(OrderProposal, pk=self.kwargs["order_proposal_id"])
|
|
69
|
+
|
|
70
|
+
@cached_property
|
|
71
|
+
def portfolio_total_asset_value(self):
|
|
72
|
+
return self.order_proposal.portfolio_total_asset_value
|
|
73
|
+
|
|
74
|
+
def has_import_permission(self, request) -> bool: # allow import only on draft order proposal
|
|
75
|
+
return super().has_import_permission(request) and self.order_proposal.status == OrderProposal.Status.DRAFT
|
|
76
|
+
|
|
77
|
+
def get_import_resource_kwargs(self):
|
|
78
|
+
resource_kwargs = super().get_import_resource_kwargs()
|
|
79
|
+
resource_kwargs["columns_mapping"] = {"underlying_instrument": "underlying_instrument__isin"}
|
|
80
|
+
return resource_kwargs
|
|
81
|
+
|
|
82
|
+
def get_resource_class(self):
|
|
83
|
+
return OrderProposalTradeResource
|
|
84
|
+
|
|
85
|
+
def get_aggregates(self, queryset, *args, **kwargs):
|
|
86
|
+
agg = {}
|
|
87
|
+
if queryset.exists():
|
|
88
|
+
noncash_aggregates = queryset.filter(underlying_instrument__is_cash=False).aggregate(
|
|
89
|
+
sum_target_weight=Sum(F("target_weight")),
|
|
90
|
+
sum_effective_weight=Sum(F("effective_weight")),
|
|
91
|
+
sum_target_total_value_fx_portfolio=Sum(F("target_total_value_fx_portfolio")),
|
|
92
|
+
sum_effective_total_value_fx_portfolio=Sum(F("effective_total_value_fx_portfolio")),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# weights aggregates
|
|
96
|
+
cash_sum_effective_weight = Decimal("1.0") - noncash_aggregates["sum_effective_weight"]
|
|
97
|
+
cash_sum_target_cash_weight = Decimal("1.0") - noncash_aggregates["sum_target_weight"]
|
|
98
|
+
noncash_sum_effective_weight = noncash_aggregates["sum_effective_weight"] or Decimal(0)
|
|
99
|
+
noncash_sum_target_weight = noncash_aggregates["sum_target_weight"] or Decimal(0)
|
|
100
|
+
sum_buy_weight = queryset.filter(weighting__gte=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
101
|
+
sum_sell_weight = queryset.filter(weighting__lt=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
102
|
+
|
|
103
|
+
# shares aggregates
|
|
104
|
+
cash_sum_effective_total_value_fx_portfolio = cash_sum_effective_weight * self.portfolio_total_asset_value
|
|
105
|
+
cash_sum_target_total_value_fx_portfolio = cash_sum_target_cash_weight * self.portfolio_total_asset_value
|
|
106
|
+
noncash_sum_effective_total_value_fx_portfolio = noncash_aggregates[
|
|
107
|
+
"sum_effective_total_value_fx_portfolio"
|
|
108
|
+
] or Decimal(0)
|
|
109
|
+
noncash_sum_target_total_value_fx_portfolio = noncash_aggregates[
|
|
110
|
+
"sum_target_total_value_fx_portfolio"
|
|
111
|
+
] or Decimal(0)
|
|
112
|
+
sum_buy_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__gte=0).aggregate(
|
|
113
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
114
|
+
)["s"] or Decimal(0)
|
|
115
|
+
sum_sell_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__lt=0).aggregate(
|
|
116
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
117
|
+
)["s"] or Decimal(0)
|
|
118
|
+
|
|
119
|
+
agg = {
|
|
120
|
+
"effective_weight": {
|
|
121
|
+
"Cash": format_number(cash_sum_effective_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
122
|
+
"Non-Cash": format_number(noncash_sum_effective_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
123
|
+
"Total": format_number(
|
|
124
|
+
noncash_sum_effective_weight + cash_sum_effective_weight,
|
|
125
|
+
decimal=Order.ORDER_WEIGHTING_PRECISION,
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
"target_weight": {
|
|
129
|
+
"Cash": format_number(cash_sum_target_cash_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
130
|
+
"Non-Cash": format_number(noncash_sum_target_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
131
|
+
"Total": format_number(
|
|
132
|
+
cash_sum_target_cash_weight + noncash_sum_target_weight,
|
|
133
|
+
decimal=Order.ORDER_WEIGHTING_PRECISION,
|
|
134
|
+
),
|
|
135
|
+
},
|
|
136
|
+
"effective_total_value_fx_portfolio": {
|
|
137
|
+
"Cash": format_number(cash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
138
|
+
"Non-Cash": format_number(noncash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
139
|
+
"Total": format_number(
|
|
140
|
+
cash_sum_effective_total_value_fx_portfolio + noncash_sum_effective_total_value_fx_portfolio,
|
|
141
|
+
decimal=6,
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
"target_total_value_fx_portfolio": {
|
|
145
|
+
"Cash": format_number(cash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
146
|
+
"Non-Cash": format_number(noncash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
147
|
+
"Total": format_number(
|
|
148
|
+
cash_sum_target_total_value_fx_portfolio + noncash_sum_target_total_value_fx_portfolio,
|
|
149
|
+
decimal=6,
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
"weighting": {
|
|
153
|
+
"Cash Flow": format_number(
|
|
154
|
+
cash_sum_target_cash_weight - cash_sum_effective_weight,
|
|
155
|
+
decimal=Order.ORDER_WEIGHTING_PRECISION,
|
|
156
|
+
),
|
|
157
|
+
"Buy": format_number(sum_buy_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
158
|
+
"Sell": format_number(sum_sell_weight, decimal=Order.ORDER_WEIGHTING_PRECISION),
|
|
159
|
+
},
|
|
160
|
+
"total_value_fx_portfolio": {
|
|
161
|
+
"Cash Flow": format_number(
|
|
162
|
+
cash_sum_target_total_value_fx_portfolio - cash_sum_effective_total_value_fx_portfolio,
|
|
163
|
+
decimal=6,
|
|
164
|
+
),
|
|
165
|
+
"Buy": format_number(sum_buy_total_value_fx_portfolio, decimal=6),
|
|
166
|
+
"Sell": format_number(sum_sell_total_value_fx_portfolio, decimal=6),
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return agg
|
|
171
|
+
|
|
172
|
+
def get_serializer_class(self):
|
|
173
|
+
if self.order_proposal.status != OrderProposal.Status.DRAFT:
|
|
174
|
+
return ReadOnlyOrderOrderProposalModelSerializer
|
|
175
|
+
elif not self.new_mode and "pk" not in self.kwargs:
|
|
176
|
+
return OrderOrderProposalListModelSerializer
|
|
177
|
+
return OrderOrderProposalModelSerializer
|
|
178
|
+
|
|
179
|
+
def add_messages(self, request, queryset=None, paginated_queryset=None, instance=None, initial=False):
|
|
180
|
+
if queryset is not None and queryset.exists() and self.order_proposal.status != OrderProposal.Status.APPROVED:
|
|
181
|
+
total_target_weight = queryset.aggregate(c=Sum(F("target_weight")))["c"] or Decimal(0)
|
|
182
|
+
if round(total_target_weight, 8) != 1:
|
|
183
|
+
warning(
|
|
184
|
+
request,
|
|
185
|
+
"The total target weight does not equal 1. To avoid automatic cash allocation, please adjust the order weights to sum up to 1. Otherwise, a cash component will be added when this order proposal is submitted.",
|
|
186
|
+
)
|
|
187
|
+
if queryset.filter(has_warnings=True).exists():
|
|
188
|
+
error(
|
|
189
|
+
request,
|
|
190
|
+
"Some orders failed preparation. To resolve this, please revert the order proposal to draft, review and correct the orders, and then resubmit.",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@cached_property
|
|
194
|
+
def orders(self):
|
|
195
|
+
if self.is_portfolio_manager:
|
|
196
|
+
return self.order_proposal.get_orders()
|
|
197
|
+
else:
|
|
198
|
+
return Order.objects.none()
|
|
199
|
+
|
|
200
|
+
def get_queryset(self):
|
|
201
|
+
return self.orders.annotate(
|
|
202
|
+
underlying_instrument_isin=F("underlying_instrument__isin"),
|
|
203
|
+
underlying_instrument_ticker=F("underlying_instrument__ticker"),
|
|
204
|
+
underlying_instrument_refinitiv_identifier_code=F("underlying_instrument__refinitiv_identifier_code"),
|
|
205
|
+
underlying_instrument_instrument_type=Case(
|
|
206
|
+
When(
|
|
207
|
+
underlying_instrument__parent__is_security=True,
|
|
208
|
+
then=F("underlying_instrument__parent__instrument_type__short_name"),
|
|
209
|
+
),
|
|
210
|
+
default=F("underlying_instrument__instrument_type__short_name"),
|
|
211
|
+
),
|
|
212
|
+
effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
|
|
213
|
+
target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
|
|
214
|
+
portfolio_currency=F("portfolio__currency__symbol"),
|
|
215
|
+
security=F("underlying_instrument__parent"),
|
|
216
|
+
company=F("underlying_instrument__parent__parent"),
|
|
217
|
+
).select_related(
|
|
218
|
+
"underlying_instrument", "underlying_instrument__parent", "underlying_instrument__parent__parent"
|
|
219
|
+
)
|