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.

Files changed (62) hide show
  1. wbportfolio/admin/indexes.py +1 -1
  2. wbportfolio/admin/product_groups.py +1 -1
  3. wbportfolio/admin/products.py +2 -1
  4. wbportfolio/admin/rebalancing.py +1 -1
  5. wbportfolio/api_clients/__init__.py +0 -0
  6. wbportfolio/api_clients/ubs.py +150 -0
  7. wbportfolio/factories/orders/order_proposals.py +3 -1
  8. wbportfolio/factories/portfolios.py +1 -1
  9. wbportfolio/factories/rebalancing.py +1 -1
  10. wbportfolio/filters/orders/__init__.py +1 -0
  11. wbportfolio/filters/orders/order_proposals.py +58 -0
  12. wbportfolio/filters/portfolios.py +20 -0
  13. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  14. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  15. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  16. wbportfolio/import_export/backends/utils.py +0 -17
  17. wbportfolio/import_export/handlers/asset_position.py +1 -1
  18. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  19. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  20. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  21. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  22. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  23. wbportfolio/models/builder.py +70 -25
  24. wbportfolio/models/mixins/instruments.py +7 -0
  25. wbportfolio/models/orders/order_proposals.py +510 -161
  26. wbportfolio/models/orders/orders.py +20 -10
  27. wbportfolio/models/orders/routing.py +54 -0
  28. wbportfolio/models/portfolio.py +76 -41
  29. wbportfolio/models/rebalancing.py +6 -6
  30. wbportfolio/models/transactions/transactions.py +10 -6
  31. wbportfolio/order_routing/__init__.py +19 -0
  32. wbportfolio/order_routing/adapters/__init__.py +57 -0
  33. wbportfolio/order_routing/adapters/ubs.py +161 -0
  34. wbportfolio/pms/trading/handler.py +4 -0
  35. wbportfolio/pms/typing.py +62 -8
  36. wbportfolio/rebalancing/models/composite.py +1 -1
  37. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  38. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  39. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  40. wbportfolio/serializers/orders/order_proposals.py +23 -3
  41. wbportfolio/serializers/orders/orders.py +5 -2
  42. wbportfolio/serializers/positions.py +2 -2
  43. wbportfolio/serializers/rebalancing.py +1 -1
  44. wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
  45. wbportfolio/tests/models/test_imports.py +5 -3
  46. wbportfolio/tests/models/test_portfolios.py +57 -23
  47. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  48. wbportfolio/tests/rebalancing/test_models.py +3 -5
  49. wbportfolio/viewsets/charts/assets.py +4 -1
  50. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  51. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  52. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  53. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +56 -12
  54. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  55. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  56. wbportfolio/viewsets/orders/order_proposals.py +45 -6
  57. wbportfolio/viewsets/orders/orders.py +31 -29
  58. wbportfolio/viewsets/portfolios.py +3 -3
  59. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +1 -1
  60. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +62 -52
  61. {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  62. {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, currency_factory, equity_factory, weekday):
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.create_batch(10, portfolio=portfolio, date=weekday)
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, recompute_weighting=True)
259
+ portfolio.change_at_date(weekday, fix_quantization=True)
259
260
 
260
- # test that change at date normalize the weighting
261
- total_value = AssetPosition.objects.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
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, portfolio, customer_trade_factory, instrument_factory, instrument_price_factory, weekday
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.set_prices({weekday: {instrument_id: p.net_value}})
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 == "SUBMIT"
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 == "SUBMIT"
1071
- rebalancing_order_proposal.approve(replay=False)
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 exising position will be updated accordingly
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(approve_order_proposal_automatically=True)
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.APPROVED
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
- a = asset_position_factory.create(portfolio=model.model_portfolio, date=model.last_effective_date)
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.APPROVED
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.APPROVED
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
- return AssetPositionGroupBy(self.request.GET.get("group_by", "classification"))
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="approve_order_proposal_automatically", label="Approve automatically"),
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", "approve_order_proposal_automatically"],
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
- return {
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
- <p>Replay Orders. It will recompute all assets positions until next order proposal day (or today otherwise) </p>
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
- <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>
47
- <p><strong>Note:</strong>This operation will change the current delta weights and cannot be undone</p>
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
- <p>Make sure all orders normalize to a total target weight of (100 - {{total_cash_weight}})%</p>
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
- <p>Delete all orders from this order proposal?</p>
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.SUBMIT.label,
33
- value=OrderProposal.Status.SUBMIT.value,
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.RED_DARK.value,
47
- label=OrderProposal.Status.FAILED.label,
48
- value=OrderProposal.Status.FAILED.value,
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.SUBMIT.value),
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", "rebalancing_model", "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=[["orders"]],
105
- grid_template_rows=["1fr"],
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 and instance.status == OrderProposal.Status.SUBMIT:
86
- if not instance.portfolio.is_manageable:
87
- info(request, "This order proposal cannot be approved the portfolio is considered unmanaged.")
88
- if instance.has_non_successful_checks:
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
- "This order proposal cannot be approved because there is unsuccessful pre-trade checks. Please rectify accordingly and resubmit a valid order proposal",
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