wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.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/admin/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/builder.py +70 -25
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +510 -161
- wbportfolio/models/orders/orders.py +20 -10
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +76 -41
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -0
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +23 -3
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/viewsets/charts/assets.py +4 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +45 -6
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,6 +10,7 @@ from django.forms.models import model_to_dict
|
|
|
10
10
|
from faker import Faker
|
|
11
11
|
from pandas.tseries.offsets import BDay
|
|
12
12
|
from psycopg.types.range import DateRange
|
|
13
|
+
from wbcore.contrib.currency.factories import CurrencyFactory
|
|
13
14
|
from wbcore.contrib.geography.factories import CountryFactory
|
|
14
15
|
|
|
15
16
|
from wbportfolio.models import (
|
|
@@ -106,11 +107,10 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
106
107
|
)
|
|
107
108
|
assert portfolio.get_geographical_breakdown(weekday).shape[0] == 2
|
|
108
109
|
|
|
109
|
-
def test_get_currency_exposure(self, portfolio, asset_position_factory,
|
|
110
|
+
def test_get_currency_exposure(self, portfolio, asset_position_factory, equity_factory, weekday):
|
|
110
111
|
a1 = asset_position_factory.create(
|
|
111
112
|
portfolio=portfolio,
|
|
112
|
-
underlying_instrument=equity_factory.create(),
|
|
113
|
-
currency=currency_factory.create(),
|
|
113
|
+
underlying_instrument=equity_factory.create(currency=CurrencyFactory.create()),
|
|
114
114
|
date=weekday,
|
|
115
115
|
)
|
|
116
116
|
asset_position_factory.create(
|
|
@@ -118,8 +118,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
118
118
|
)
|
|
119
119
|
asset_position_factory.create(
|
|
120
120
|
portfolio=portfolio,
|
|
121
|
-
underlying_instrument=equity_factory.create(),
|
|
122
|
-
currency=currency_factory.create(),
|
|
121
|
+
underlying_instrument=equity_factory.create(currency=CurrencyFactory.create()),
|
|
123
122
|
date=weekday,
|
|
124
123
|
)
|
|
125
124
|
assert portfolio.get_currency_exposure(weekday).shape[0] == 2
|
|
@@ -253,14 +252,14 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
253
252
|
|
|
254
253
|
@patch.object(Portfolio, "estimate_net_asset_values", autospec=True)
|
|
255
254
|
def test_change_at_date(self, mock_estimate_net_asset_values, asset_position_factory, portfolio, weekday):
|
|
256
|
-
asset_position_factory.
|
|
255
|
+
a1 = asset_position_factory.create(portfolio=portfolio, date=weekday, weighting=Decimal("0.1"))
|
|
256
|
+
a2 = asset_position_factory.create(portfolio=portfolio, date=weekday, weighting=Decimal("0.3"))
|
|
257
|
+
a3 = asset_position_factory.create(portfolio=portfolio, date=weekday, weighting=Decimal("0.5"))
|
|
257
258
|
|
|
258
|
-
portfolio.change_at_date(weekday,
|
|
259
|
+
portfolio.change_at_date(weekday, fix_quantization=True)
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
for pos in AssetPosition.objects.all():
|
|
263
|
-
assert float(pos.weighting) == pytest.approx(float(pos.total_value_fx_portfolio / total_value), rel=1e-2)
|
|
261
|
+
cash_pos = portfolio.assets.get(date=weekday, portfolio=portfolio, underlying_quote=portfolio.cash_component)
|
|
262
|
+
assert cash_pos.weighting == Decimal("1.0") - (a1.weighting + a2.weighting + a3.weighting)
|
|
264
263
|
|
|
265
264
|
mock_estimate_net_asset_values.assert_called_once_with(
|
|
266
265
|
portfolio, (weekday + BDay(1)).date(), analytic_portfolio=None
|
|
@@ -530,8 +529,9 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
530
529
|
assert res1
|
|
531
530
|
|
|
532
531
|
def test_get_total_asset_under_management(
|
|
533
|
-
self,
|
|
532
|
+
self, portfolio_factory, customer_trade_factory, instrument_factory, instrument_price_factory, weekday
|
|
534
533
|
):
|
|
534
|
+
portfolio = portfolio_factory.create()
|
|
535
535
|
i1 = instrument_factory.create()
|
|
536
536
|
i2 = instrument_factory.create()
|
|
537
537
|
previous_day = (weekday - BDay(5)).date()
|
|
@@ -1001,8 +1001,8 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1001
1001
|
fx_portfolio = currency_fx_rates_factory.create(currency=portfolio.currency, date=weekday)
|
|
1002
1002
|
fx_instrument = currency_fx_rates_factory.create(currency=instrument.currency, date=weekday)
|
|
1003
1003
|
instrument_id: int = instrument.id
|
|
1004
|
-
weights = {instrument_id: random.random()}
|
|
1005
|
-
portfolio.builder.
|
|
1004
|
+
weights = {instrument_id: Decimal(random.random())}
|
|
1005
|
+
portfolio.builder.prices = {weekday: {instrument_id: p.net_value}}
|
|
1006
1006
|
portfolio.builder.add((weekday, weights), infer_underlying_quote_price=False)
|
|
1007
1007
|
|
|
1008
1008
|
res = list(portfolio.builder.get_positions())
|
|
@@ -1012,7 +1012,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1012
1012
|
assert a.underlying_quote == instrument
|
|
1013
1013
|
assert a.underlying_quote_price == None
|
|
1014
1014
|
assert a.initial_price == p.net_value
|
|
1015
|
-
assert a.weighting == pytest.approx(weights[instrument.id], abs=10e-6)
|
|
1015
|
+
assert a.weighting == pytest.approx(weights[instrument.id], abs=Decimal(10e-6))
|
|
1016
1016
|
assert a.currency_fx_rate_portfolio_to_usd == fx_portfolio
|
|
1017
1017
|
assert a.currency_fx_rate_instrument_to_usd == fx_instrument
|
|
1018
1018
|
|
|
@@ -1057,7 +1057,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1057
1057
|
except StopIteration as e:
|
|
1058
1058
|
rebalancing_order_proposal = e.value
|
|
1059
1059
|
assert rebalancing_order_proposal.trade_date == rebalancing_date
|
|
1060
|
-
assert rebalancing_order_proposal.status == "
|
|
1060
|
+
assert rebalancing_order_proposal.status == "APPROVED"
|
|
1061
1061
|
|
|
1062
1062
|
# we expect a equally rebalancing (default) so both orders needs to be created
|
|
1063
1063
|
orders = rebalancing_order_proposal.get_orders()
|
|
@@ -1067,8 +1067,8 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1067
1067
|
assert t2._target_weight == Decimal("0.5")
|
|
1068
1068
|
|
|
1069
1069
|
# we approve the rebalancing order proposal
|
|
1070
|
-
assert rebalancing_order_proposal.status == "
|
|
1071
|
-
rebalancing_order_proposal.
|
|
1070
|
+
assert rebalancing_order_proposal.status == "APPROVED"
|
|
1071
|
+
rebalancing_order_proposal.apply(replay=False)
|
|
1072
1072
|
rebalancing_order_proposal.save()
|
|
1073
1073
|
|
|
1074
1074
|
# check that the rebalancing was applied and position reflect that
|
|
@@ -1084,17 +1084,17 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1084
1084
|
a1 = asset_position_factory.build(date=weekday, portfolio=portfolio, underlying_instrument=i1)
|
|
1085
1085
|
|
|
1086
1086
|
# check initial creation
|
|
1087
|
-
portfolio.builder.add([a1]).bulk_create_positions(compute_metrics=True)
|
|
1087
|
+
portfolio.builder.add([a1]).bulk_create_positions(fix_quantization=False, compute_metrics=True)
|
|
1088
1088
|
assert AssetPosition.objects.get(portfolio=portfolio, date=weekday).weighting == a1.weighting
|
|
1089
1089
|
assert AssetPosition.objects.get(portfolio=portfolio, date=weekday).underlying_instrument == i1
|
|
1090
1090
|
|
|
1091
|
-
# check that if we change key value, an already
|
|
1091
|
+
# check that if we change key value, an already existing position will be updated accordingly
|
|
1092
1092
|
a1.weighting = Decimal(0.5)
|
|
1093
|
-
portfolio.builder.add([a1]).bulk_create_positions()
|
|
1093
|
+
portfolio.builder.add([a1]).bulk_create_positions(fix_quantization=False)
|
|
1094
1094
|
assert AssetPosition.objects.get(portfolio=portfolio, date=weekday).weighting == Decimal(0.5)
|
|
1095
1095
|
|
|
1096
1096
|
a2 = asset_position_factory.build(date=weekday, portfolio=portfolio, underlying_instrument=i2)
|
|
1097
|
-
portfolio.builder.add([a2]).bulk_create_positions()
|
|
1097
|
+
portfolio.builder.add([a2]).bulk_create_positions(fix_quantization=False)
|
|
1098
1098
|
assert (
|
|
1099
1099
|
AssetPosition.objects.get(portfolio=portfolio, date=weekday, underlying_instrument=i1).weighting
|
|
1100
1100
|
== a1.weighting
|
|
@@ -1105,7 +1105,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1105
1105
|
)
|
|
1106
1106
|
|
|
1107
1107
|
a3 = asset_position_factory.build(date=weekday, portfolio=portfolio, underlying_instrument=i3)
|
|
1108
|
-
portfolio.builder.add([a3]).bulk_create_positions(delete_leftovers=True)
|
|
1108
|
+
portfolio.builder.add([a3]).bulk_create_positions(delete_leftovers=True, fix_quantization=False)
|
|
1109
1109
|
assert AssetPosition.objects.get(portfolio=portfolio, date=weekday).weighting == a3.weighting
|
|
1110
1110
|
assert AssetPosition.objects.get(portfolio=portfolio, date=weekday).underlying_instrument == i3
|
|
1111
1111
|
|
|
@@ -1136,3 +1136,37 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1136
1136
|
|
|
1137
1137
|
primary_portfolio.handle_controlling_portfolio_change_at_date(weekday)
|
|
1138
1138
|
mock_compute_lookthrough.assert_called_once_with(lookthrough_portfolio, weekday)
|
|
1139
|
+
|
|
1140
|
+
def test_get_model_portfolio_relationships(self, portfolio_factory, asset_position_factory, weekday):
|
|
1141
|
+
model_portfolio = portfolio_factory.create()
|
|
1142
|
+
model_index = model_portfolio.get_or_create_index()
|
|
1143
|
+
dependent_portfolio = portfolio_factory.create()
|
|
1144
|
+
re1 = PortfolioPortfolioThroughModel.objects.create(
|
|
1145
|
+
portfolio=dependent_portfolio,
|
|
1146
|
+
dependency_portfolio=model_portfolio,
|
|
1147
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
assert set(model_portfolio.get_model_portfolio_relationships(weekday)) == {
|
|
1151
|
+
re1,
|
|
1152
|
+
}
|
|
1153
|
+
parent_portfolio = portfolio_factory.create()
|
|
1154
|
+
child_portfolio = portfolio_factory.create()
|
|
1155
|
+
re2 = PortfolioPortfolioThroughModel.objects.create(
|
|
1156
|
+
portfolio=child_portfolio,
|
|
1157
|
+
dependency_portfolio=parent_portfolio,
|
|
1158
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
1159
|
+
)
|
|
1160
|
+
assert set(model_portfolio.get_model_portfolio_relationships(weekday)) == {
|
|
1161
|
+
re1,
|
|
1162
|
+
} # child portfolio is not considered in the tree because there is no position yet
|
|
1163
|
+
|
|
1164
|
+
asset_position_factory.create(portfolio=parent_portfolio, underlying_instrument=model_index, date=weekday)
|
|
1165
|
+
assert set(model_portfolio.get_model_portfolio_relationships(weekday)) == {re1, re2}
|
|
1166
|
+
|
|
1167
|
+
dependent_portfolio.is_active = False # disable this portfolio
|
|
1168
|
+
dependent_portfolio.deletion_datetime = weekday - timedelta(days=1)
|
|
1169
|
+
dependent_portfolio.save()
|
|
1170
|
+
assert set(model_portfolio.get_model_portfolio_relationships(weekday)) == {
|
|
1171
|
+
re2,
|
|
1172
|
+
}
|
|
@@ -47,7 +47,7 @@ class TestRebalancer:
|
|
|
47
47
|
def test_evaluate_rebalancing(
|
|
48
48
|
self, weekday, rebalancer_factory, asset_position_factory, instrument_factory, instrument_price_factory
|
|
49
49
|
):
|
|
50
|
-
rebalancer = rebalancer_factory.create(
|
|
50
|
+
rebalancer = rebalancer_factory.create(apply_order_proposal_automatically=True)
|
|
51
51
|
trade_date = (weekday + BDay(1)).date()
|
|
52
52
|
|
|
53
53
|
i1 = instrument_factory.create()
|
|
@@ -65,7 +65,7 @@ class TestRebalancer:
|
|
|
65
65
|
)
|
|
66
66
|
order_proposal = rebalancer.evaluate_rebalancing(trade_date)
|
|
67
67
|
assert order_proposal.orders.count() == 2
|
|
68
|
-
assert order_proposal.status == OrderProposal.Status.
|
|
68
|
+
assert order_proposal.status == OrderProposal.Status.APPLIED
|
|
69
69
|
assert AssetPosition.objects.get(
|
|
70
70
|
portfolio=rebalancer.portfolio, date=trade_date, underlying_quote=a1.underlying_instrument
|
|
71
71
|
).weighting == Decimal(0.5)
|
|
@@ -56,9 +56,7 @@ class TestModelPortfolioRebalancing:
|
|
|
56
56
|
asset_position_factory.create(portfolio=model.portfolio, date=model.last_effective_date)
|
|
57
57
|
assert not model.is_valid()
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
assert not model.is_valid()
|
|
61
|
-
instrument_price_factory.create(instrument=a.underlying_quote, date=model.trade_date)
|
|
59
|
+
asset_position_factory.create(portfolio=model.model_portfolio, date=model.last_effective_date)
|
|
62
60
|
assert model.is_valid()
|
|
63
61
|
|
|
64
62
|
def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
|
|
@@ -84,7 +82,7 @@ class TestCompositeRebalancing:
|
|
|
84
82
|
assert not model.is_valid()
|
|
85
83
|
|
|
86
84
|
order_proposal = OrderProposalFactory.create(
|
|
87
|
-
portfolio=model.portfolio, trade_date=model.last_effective_date, status=OrderProposal.Status.
|
|
85
|
+
portfolio=model.portfolio, trade_date=model.last_effective_date, status=OrderProposal.Status.APPLIED
|
|
88
86
|
)
|
|
89
87
|
t1 = OrderFactory.create(
|
|
90
88
|
portfolio=model.portfolio,
|
|
@@ -106,7 +104,7 @@ class TestCompositeRebalancing:
|
|
|
106
104
|
|
|
107
105
|
def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
|
|
108
106
|
order_proposal = OrderProposalFactory.create(
|
|
109
|
-
portfolio=model.portfolio, trade_date=model.last_effective_date, status=OrderProposal.Status.
|
|
107
|
+
portfolio=model.portfolio, trade_date=model.last_effective_date, status=OrderProposal.Status.APPLIED
|
|
110
108
|
)
|
|
111
109
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
112
110
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
@@ -71,7 +71,10 @@ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
|
|
|
71
71
|
|
|
72
72
|
@cached_property
|
|
73
73
|
def group_by(self) -> AssetPositionGroupBy:
|
|
74
|
-
|
|
74
|
+
try:
|
|
75
|
+
return AssetPositionGroupBy(self.request.GET.get("group_by", "classification"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return AssetPositionGroupBy.INDUSTRY
|
|
75
78
|
|
|
76
79
|
@cached_property
|
|
77
80
|
def val_date(self) -> dt.date:
|
|
@@ -11,7 +11,7 @@ class RebalancerDisplayConfig(DisplayViewConfig):
|
|
|
11
11
|
fields=[
|
|
12
12
|
dp.Field(key="portfolio", label="Portfolio"),
|
|
13
13
|
dp.Field(key="rebalancing_model", label="Rebalancing Model"),
|
|
14
|
-
dp.Field(key="
|
|
14
|
+
dp.Field(key="apply_order_proposal_automatically", label="Approve automatically"),
|
|
15
15
|
dp.Field(key="frequency_repr", label="Frequency"),
|
|
16
16
|
dp.Field(key="activation_date", label="Activation Date"),
|
|
17
17
|
],
|
|
@@ -20,7 +20,7 @@ class RebalancerDisplayConfig(DisplayViewConfig):
|
|
|
20
20
|
def get_instance_display(self) -> Display:
|
|
21
21
|
return create_simple_display(
|
|
22
22
|
[
|
|
23
|
-
["rebalancing_model", "
|
|
23
|
+
["rebalancing_model", "apply_order_proposal_automatically"],
|
|
24
24
|
["frequency", "activation_date"],
|
|
25
25
|
["rebalancing_dates", "rebalancing_dates"],
|
|
26
26
|
]
|
|
@@ -27,3 +27,4 @@ from .roles import PORTFOLIOROLE_MENUITEM
|
|
|
27
27
|
from .trades import SUBSCRIPTION_REDEMPTION_MENUITEM, TRADE_MENUITEM
|
|
28
28
|
from .portfolio_cash_flow import PORTFOLIO_DAILY_CASH_FLOW
|
|
29
29
|
from .reconciliations import ACCOUNT_RECONCILIATION_MENU_ITEM
|
|
30
|
+
from .orders import OrderProposalMenuItem
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from wbcore.menus import ItemPermission, MenuItem
|
|
2
|
+
from wbcore.permissions.shortcuts import is_internal_user
|
|
3
|
+
|
|
4
|
+
OrderProposalMenuItem = MenuItem(
|
|
5
|
+
label="Order Proposals",
|
|
6
|
+
endpoint="wbportfolio:orderproposal-list",
|
|
7
|
+
endpoint_get_parameters={"waiting_for_input": True},
|
|
8
|
+
permission=ItemPermission(
|
|
9
|
+
permissions=["wbportfolio.view_orderproposal"], method=lambda request: is_internal_user(request.user)
|
|
10
|
+
),
|
|
11
|
+
)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
|
|
3
|
+
from rest_framework.reverse import reverse
|
|
1
4
|
from wbcore import serializers as wb_serializers
|
|
2
5
|
from wbcore.contrib.icons import WBIcon
|
|
3
6
|
from wbcore.enums import RequestType
|
|
@@ -5,6 +8,9 @@ from wbcore.metadata.configs import buttons as bt
|
|
|
5
8
|
from wbcore.metadata.configs.buttons.view_config import ButtonViewConfig
|
|
6
9
|
from wbcore.metadata.configs.display import create_simple_display
|
|
7
10
|
|
|
11
|
+
from wbportfolio.models import OrderProposal, Portfolio
|
|
12
|
+
from wbportfolio.serializers import PortfolioRepresentationSerializer
|
|
13
|
+
|
|
8
14
|
|
|
9
15
|
class NormalizeSerializer(wb_serializers.Serializer):
|
|
10
16
|
total_cash_weight = wb_serializers.FloatField(default=0, precision=4, percent=True)
|
|
@@ -20,7 +26,44 @@ class ResetSerializer(wb_serializers.Serializer):
|
|
|
20
26
|
|
|
21
27
|
class OrderProposalButtonConfig(ButtonViewConfig):
|
|
22
28
|
def get_custom_list_instance_buttons(self):
|
|
23
|
-
|
|
29
|
+
buttons = []
|
|
30
|
+
with suppress(AttributeError, AssertionError):
|
|
31
|
+
order_proposal = self.view.get_object()
|
|
32
|
+
if order_proposal.status == OrderProposal.Status.APPLIED.value and order_proposal.portfolio.is_model:
|
|
33
|
+
|
|
34
|
+
class PushModelChangeSerializer(wb_serializers.Serializer):
|
|
35
|
+
only_for_portfolio_ids = wb_serializers.PrimaryKeyRelatedField(
|
|
36
|
+
label="Only for Portfolios", queryset=Portfolio.objects.all()
|
|
37
|
+
)
|
|
38
|
+
_only_for_portfolio_ids = PortfolioRepresentationSerializer(
|
|
39
|
+
source="only_for_portfolio_ids",
|
|
40
|
+
many=True,
|
|
41
|
+
filter_params={"modeled_after": order_proposal.portfolio.id},
|
|
42
|
+
)
|
|
43
|
+
approve_automatically = wb_serializers.BooleanField(
|
|
44
|
+
default=False,
|
|
45
|
+
help_text="True if you want all created orders to be automatically move to the approve state.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
buttons.append(
|
|
49
|
+
bt.ActionButton(
|
|
50
|
+
method=RequestType.PATCH,
|
|
51
|
+
identifiers=("wbportfolio:orderproposal",),
|
|
52
|
+
endpoint=reverse("wbportfolio:orderproposal-pushmodelchange", args=[order_proposal.id]),
|
|
53
|
+
icon=WBIcon.BROADCAST.icon,
|
|
54
|
+
label="Push Model Changes",
|
|
55
|
+
description_fields=f"""
|
|
56
|
+
Push this rebalancing to all portfolios that are modeled after {order_proposal.portfolio}
|
|
57
|
+
""",
|
|
58
|
+
action_label="Push Model Changes",
|
|
59
|
+
title="Push Model Changes",
|
|
60
|
+
serializer=PushModelChangeSerializer,
|
|
61
|
+
instance_display=create_simple_display(
|
|
62
|
+
[["only_for_portfolio_ids"], ["approve_automatically"]]
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
buttons.append(
|
|
24
67
|
bt.DropDownButton(
|
|
25
68
|
label="Tools",
|
|
26
69
|
buttons=(
|
|
@@ -31,8 +74,8 @@ class OrderProposalButtonConfig(ButtonViewConfig):
|
|
|
31
74
|
icon=WBIcon.SYNCHRONIZE.icon,
|
|
32
75
|
label="Replay Orders",
|
|
33
76
|
description_fields="""
|
|
34
|
-
|
|
35
|
-
|
|
77
|
+
<p>Replay Orders. It will recompute all assets positions until next order proposal day (or today otherwise) </p>
|
|
78
|
+
""",
|
|
36
79
|
action_label="Replay Order",
|
|
37
80
|
title="Replay Order",
|
|
38
81
|
),
|
|
@@ -43,9 +86,9 @@ class OrderProposalButtonConfig(ButtonViewConfig):
|
|
|
43
86
|
icon=WBIcon.REGENERATE.icon,
|
|
44
87
|
label="Reset Orders",
|
|
45
88
|
description_fields="""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
89
|
+
<p><strong>Warning:</strong>This action will reset the order delta weight to either 0 or the difference between the previous weight and the locked target weight, depending on the user’s choice.</p>
|
|
90
|
+
<p><strong>Note:</strong>This operation will change the current delta weights and cannot be undone</p>
|
|
91
|
+
""",
|
|
49
92
|
action_label="Reset Orders",
|
|
50
93
|
title="Reset Orders",
|
|
51
94
|
serializer=ResetSerializer,
|
|
@@ -58,8 +101,8 @@ class OrderProposalButtonConfig(ButtonViewConfig):
|
|
|
58
101
|
icon=WBIcon.EDIT.icon,
|
|
59
102
|
label="Normalize Orders",
|
|
60
103
|
description_fields="""
|
|
61
|
-
|
|
62
|
-
|
|
104
|
+
<p>Make sure all orders normalize to a total target weight of (100 - {{total_cash_weight}})%</p>
|
|
105
|
+
""",
|
|
63
106
|
action_label="Normalize Orders",
|
|
64
107
|
title="Normalize Orders",
|
|
65
108
|
serializer=NormalizeSerializer,
|
|
@@ -72,14 +115,15 @@ class OrderProposalButtonConfig(ButtonViewConfig):
|
|
|
72
115
|
icon=WBIcon.DELETE.icon,
|
|
73
116
|
label="Delete All Orders",
|
|
74
117
|
description_fields="""
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
<p>Delete all orders from this order proposal?</p>
|
|
119
|
+
""",
|
|
77
120
|
action_label="Delete All Orders",
|
|
78
121
|
title="Delete All Orders",
|
|
79
122
|
),
|
|
80
123
|
),
|
|
81
|
-
)
|
|
82
|
-
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return set(buttons)
|
|
83
127
|
|
|
84
128
|
def get_custom_instance_buttons(self):
|
|
85
129
|
return self.get_custom_list_instance_buttons()
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
1
2
|
from typing import Optional
|
|
2
3
|
|
|
3
4
|
from wbcore.contrib.color.enums import WBColor
|
|
4
5
|
from wbcore.metadata.configs import display as dp
|
|
6
|
+
from wbcore.metadata.configs.display import Section
|
|
5
7
|
from wbcore.metadata.configs.display.instance_display import Inline, Layout, Page
|
|
6
8
|
from wbcore.metadata.configs.display.instance_display.operators import default
|
|
7
9
|
from wbcore.metadata.configs.display.instance_display.shortcuts import Display
|
|
@@ -14,9 +16,12 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
14
16
|
def get_list_display(self) -> Optional[dp.ListDisplay]:
|
|
15
17
|
return dp.ListDisplay(
|
|
16
18
|
fields=[
|
|
19
|
+
dp.Field(key="portfolio", label="Portfolio") if "portfolio_id" not in self.view.kwargs else None,
|
|
17
20
|
dp.Field(key="trade_date", label="Order Date"),
|
|
18
21
|
dp.Field(key="rebalancing_model", label="Rebalancing Model"),
|
|
19
22
|
dp.Field(key="comment", label="Comment"),
|
|
23
|
+
dp.Field(key="creator", label="Creator"),
|
|
24
|
+
dp.Field(key="approver", label="Approver"),
|
|
20
25
|
],
|
|
21
26
|
legends=[
|
|
22
27
|
dp.Legend(
|
|
@@ -29,8 +34,8 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
29
34
|
),
|
|
30
35
|
dp.LegendItem(
|
|
31
36
|
icon=WBColor.YELLOW_LIGHT.value,
|
|
32
|
-
label=OrderProposal.Status.
|
|
33
|
-
value=OrderProposal.Status.
|
|
37
|
+
label=OrderProposal.Status.PENDING.label,
|
|
38
|
+
value=OrderProposal.Status.PENDING.value,
|
|
34
39
|
),
|
|
35
40
|
dp.LegendItem(
|
|
36
41
|
icon=WBColor.GREEN_LIGHT.value,
|
|
@@ -43,9 +48,14 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
43
48
|
value=OrderProposal.Status.DENIED.value,
|
|
44
49
|
),
|
|
45
50
|
dp.LegendItem(
|
|
46
|
-
icon=WBColor.
|
|
47
|
-
label=OrderProposal.Status.
|
|
48
|
-
value=OrderProposal.Status.
|
|
51
|
+
icon=WBColor.GREEN.value,
|
|
52
|
+
label=OrderProposal.Status.APPLIED.label,
|
|
53
|
+
value=OrderProposal.Status.APPLIED.value,
|
|
54
|
+
),
|
|
55
|
+
dp.LegendItem(
|
|
56
|
+
icon=WBColor.GREY.value,
|
|
57
|
+
label=OrderProposal.Status.EXECUTION.label,
|
|
58
|
+
value=OrderProposal.Status.EXECUTION.value,
|
|
49
59
|
),
|
|
50
60
|
],
|
|
51
61
|
),
|
|
@@ -60,12 +70,20 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
60
70
|
),
|
|
61
71
|
dp.FormattingRule(
|
|
62
72
|
style={"backgroundColor": WBColor.YELLOW_LIGHT.value},
|
|
63
|
-
condition=("==", OrderProposal.Status.
|
|
73
|
+
condition=("==", OrderProposal.Status.PENDING.value),
|
|
64
74
|
),
|
|
65
75
|
dp.FormattingRule(
|
|
66
76
|
style={"backgroundColor": WBColor.GREEN_LIGHT.value},
|
|
67
77
|
condition=("==", OrderProposal.Status.APPROVED.value),
|
|
68
78
|
),
|
|
79
|
+
dp.FormattingRule(
|
|
80
|
+
style={"backgroundColor": WBColor.GREEN.value},
|
|
81
|
+
condition=("==", OrderProposal.Status.APPLIED.value),
|
|
82
|
+
),
|
|
83
|
+
dp.FormattingRule(
|
|
84
|
+
style={"backgroundColor": WBColor.GREY.value},
|
|
85
|
+
condition=("==", OrderProposal.Status.EXECUTION.value),
|
|
86
|
+
),
|
|
69
87
|
dp.FormattingRule(
|
|
70
88
|
style={"backgroundColor": WBColor.RED_LIGHT.value},
|
|
71
89
|
condition=("==", OrderProposal.Status.DENIED.value),
|
|
@@ -80,6 +98,33 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
80
98
|
)
|
|
81
99
|
|
|
82
100
|
def get_instance_display(self) -> Display:
|
|
101
|
+
orders_grid_template_areas = [["orders"]]
|
|
102
|
+
orders_grid_template_rows = ["1fr"]
|
|
103
|
+
sections = []
|
|
104
|
+
with suppress(AttributeError, AssertionError):
|
|
105
|
+
op = self.view.get_object()
|
|
106
|
+
if op.execution_status:
|
|
107
|
+
orders_grid_template_areas = [["execution"], ["orders"]]
|
|
108
|
+
orders_grid_template_rows = ["100px", "1fr"]
|
|
109
|
+
sections.append(
|
|
110
|
+
Section(
|
|
111
|
+
key="execution",
|
|
112
|
+
title="Execution",
|
|
113
|
+
collapsed=False,
|
|
114
|
+
display=Display(
|
|
115
|
+
pages=[
|
|
116
|
+
Page(
|
|
117
|
+
layouts={
|
|
118
|
+
default(): Layout(
|
|
119
|
+
grid_template_areas=[["execution_status_repr", "execution_comment"]],
|
|
120
|
+
grid_template_columns=["0.3fr", "0.7fr"],
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
83
128
|
return Display(
|
|
84
129
|
pages=[
|
|
85
130
|
Page(
|
|
@@ -91,7 +136,7 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
91
136
|
["trade_date", "total_cash_weight", "min_order_value"],
|
|
92
137
|
["rebalancing_model", "target_portfolio", "target_portfolio"]
|
|
93
138
|
if self.view.new_mode
|
|
94
|
-
else ["rebalancing_model", "
|
|
139
|
+
else ["rebalancing_model", "creator", "approver"],
|
|
95
140
|
["comment", "comment", "comment"],
|
|
96
141
|
],
|
|
97
142
|
),
|
|
@@ -101,9 +146,10 @@ class OrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
101
146
|
title="Orders",
|
|
102
147
|
layouts={
|
|
103
148
|
default(): Layout(
|
|
104
|
-
grid_template_areas=
|
|
105
|
-
grid_template_rows=
|
|
149
|
+
grid_template_areas=orders_grid_template_areas,
|
|
150
|
+
grid_template_rows=orders_grid_template_rows,
|
|
106
151
|
inlines=[Inline(key="orders", endpoint="orders")],
|
|
152
|
+
sections=sections,
|
|
107
153
|
),
|
|
108
154
|
},
|
|
109
155
|
),
|
|
@@ -81,6 +81,7 @@ class OrderOrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
81
81
|
key="underlying_instrument_refinitiv_identifier_code", label="RIC", width=Unit.PIXEL(100)
|
|
82
82
|
),
|
|
83
83
|
dp.Field(key="underlying_instrument_instrument_type", label="Asset Class", width=Unit.PIXEL(125)),
|
|
84
|
+
dp.Field(key="underlying_instrument_exchange", label="Exchange", width=Unit.PIXEL(125)),
|
|
84
85
|
],
|
|
85
86
|
),
|
|
86
87
|
dp.Field(
|
|
@@ -161,6 +162,18 @@ class OrderOrderProposalDisplayConfig(DisplayViewConfig):
|
|
|
161
162
|
],
|
|
162
163
|
)
|
|
163
164
|
)
|
|
165
|
+
if order_proposal.execution_status:
|
|
166
|
+
fields.append(
|
|
167
|
+
dp.Field(
|
|
168
|
+
label="Execution",
|
|
169
|
+
open_by_default=True,
|
|
170
|
+
key=None,
|
|
171
|
+
children=[
|
|
172
|
+
dp.Field(key="execution_confirmed", label="Confirmed", width=Unit.PIXEL(50)),
|
|
173
|
+
dp.Field(key="execution_comment", label="Comment", width=Unit.PIXEL(100)),
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
)
|
|
164
177
|
return dp.ListDisplay(
|
|
165
178
|
fields=fields,
|
|
166
179
|
legends=[ORDER_STATUS_LEGENDS],
|
|
@@ -21,6 +21,7 @@ from wbcore.utils.views import CloneMixin
|
|
|
21
21
|
|
|
22
22
|
from wbportfolio.models import AssetPosition, OrderProposal
|
|
23
23
|
from wbportfolio.models.orders.order_proposals import (
|
|
24
|
+
push_model_change_as_task,
|
|
24
25
|
replay_as_task,
|
|
25
26
|
)
|
|
26
27
|
from wbportfolio.serializers import (
|
|
@@ -29,6 +30,8 @@ from wbportfolio.serializers import (
|
|
|
29
30
|
ReadOnlyOrderProposalModelSerializer,
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
from ...filters.orders import OrderProposalFilterSet
|
|
34
|
+
from ...order_routing import ExecutionStatus
|
|
32
35
|
from ..mixins import UserPortfolioRequestPermissionMixin
|
|
33
36
|
from .configs import (
|
|
34
37
|
OrderProposalButtonConfig,
|
|
@@ -52,6 +55,7 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
52
55
|
|
|
53
56
|
queryset = OrderProposal.objects.select_related("rebalancing_model", "portfolio")
|
|
54
57
|
serializer_class = OrderProposalModelSerializer
|
|
58
|
+
filterset_class = OrderProposalFilterSet
|
|
55
59
|
display_config_class = OrderProposalDisplayConfig
|
|
56
60
|
button_config_class = OrderProposalButtonConfig
|
|
57
61
|
endpoint_config_class = OrderProposalEndpointConfig
|
|
@@ -81,14 +85,29 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
81
85
|
]
|
|
82
86
|
)
|
|
83
87
|
|
|
84
|
-
def add_messages(self, request, instance=None, **kwargs):
|
|
85
|
-
if instance
|
|
86
|
-
if
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
def add_messages(self, request, instance: OrderProposal | None = None, **kwargs):
|
|
89
|
+
if instance:
|
|
90
|
+
if instance.status == OrderProposal.Status.PENDING:
|
|
91
|
+
if not instance.portfolio.is_manageable:
|
|
92
|
+
info(request, "This order proposal cannot be approved the portfolio is considered unmanaged.")
|
|
93
|
+
if instance.has_non_successful_checks:
|
|
94
|
+
warning(
|
|
95
|
+
request,
|
|
96
|
+
"This order proposal cannot be approved because there is unsuccessful pre-trade checks. Please rectify accordingly and resubmit a valid order proposal",
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
instance.execution_status in [ExecutionStatus.IN_DRAFT, ExecutionStatus.COMPLETED]
|
|
100
|
+
and instance.orders.filter(execution_confirmed=False).exists()
|
|
101
|
+
):
|
|
102
|
+
warning(request, "Some orders failed confirmation. Check the list for further details.")
|
|
103
|
+
if instance.execution_status in [
|
|
104
|
+
ExecutionStatus.REJECTED,
|
|
105
|
+
ExecutionStatus.FAILED,
|
|
106
|
+
ExecutionStatus.UNKNOWN,
|
|
107
|
+
]:
|
|
89
108
|
warning(
|
|
90
109
|
request,
|
|
91
|
-
"
|
|
110
|
+
f"The execution status is {ExecutionStatus[instance.execution_status].label}. Detail: {instance.execution_comment}",
|
|
92
111
|
)
|
|
93
112
|
|
|
94
113
|
@classmethod
|
|
@@ -130,6 +149,26 @@ class OrderProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
130
149
|
return Response({"send": True})
|
|
131
150
|
return Response({"status": "Order Proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
132
151
|
|
|
152
|
+
@action(detail=True, methods=["PATCH"])
|
|
153
|
+
def pushmodelchange(self, request, pk=None):
|
|
154
|
+
order_proposal = get_object_or_404(OrderProposal, pk=pk)
|
|
155
|
+
only_for_portfolio_ids = list(
|
|
156
|
+
map(lambda o: int(o), filter(lambda r: r, request.data.get("only_for_portfolio_ids", "").split(",")))
|
|
157
|
+
)
|
|
158
|
+
approve_automatically = request.data.get("approve_automatically") == "true"
|
|
159
|
+
if order_proposal.status == OrderProposal.Status.APPLIED and order_proposal.portfolio.is_model:
|
|
160
|
+
push_model_change_as_task.delay(
|
|
161
|
+
order_proposal.id,
|
|
162
|
+
request.user.id,
|
|
163
|
+
only_for_portfolio_ids=only_for_portfolio_ids,
|
|
164
|
+
approve_automatically=approve_automatically,
|
|
165
|
+
)
|
|
166
|
+
return Response({"send": True})
|
|
167
|
+
return Response(
|
|
168
|
+
{"status": "Order Proposal needs to be approved and linked to be a model portfolio"},
|
|
169
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
170
|
+
)
|
|
171
|
+
|
|
133
172
|
|
|
134
173
|
class OrderProposalPortfolioModelViewSet(UserPortfolioRequestPermissionMixin, OrderProposalModelViewSet):
|
|
135
174
|
endpoint_config_class = OrderProposalPortfolioEndpointConfig
|