wbportfolio 1.54.22__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 (79) 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/orders/orders.py +10 -2
  9. wbportfolio/factories/portfolios.py +1 -1
  10. wbportfolio/factories/rebalancing.py +1 -1
  11. wbportfolio/filters/assets.py +10 -2
  12. wbportfolio/filters/orders/__init__.py +1 -0
  13. wbportfolio/filters/orders/order_proposals.py +58 -0
  14. wbportfolio/filters/portfolios.py +20 -0
  15. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  16. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  17. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  18. wbportfolio/import_export/backends/utils.py +0 -17
  19. wbportfolio/import_export/handlers/asset_position.py +1 -1
  20. wbportfolio/import_export/handlers/orders.py +1 -1
  21. wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
  22. wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
  23. wbportfolio/import_export/parsers/ubs/equity.py +1 -1
  24. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  25. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  26. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  27. wbportfolio/models/asset.py +6 -20
  28. wbportfolio/models/builder.py +74 -31
  29. wbportfolio/models/mixins/instruments.py +7 -0
  30. wbportfolio/models/orders/order_proposals.py +549 -167
  31. wbportfolio/models/orders/orders.py +24 -11
  32. wbportfolio/models/orders/routing.py +54 -0
  33. wbportfolio/models/portfolio.py +77 -41
  34. wbportfolio/models/products.py +9 -0
  35. wbportfolio/models/rebalancing.py +6 -6
  36. wbportfolio/models/transactions/transactions.py +10 -6
  37. wbportfolio/order_routing/__init__.py +19 -0
  38. wbportfolio/order_routing/adapters/__init__.py +57 -0
  39. wbportfolio/order_routing/adapters/ubs.py +161 -0
  40. wbportfolio/pms/trading/handler.py +4 -1
  41. wbportfolio/pms/typing.py +62 -8
  42. wbportfolio/rebalancing/models/composite.py +1 -1
  43. wbportfolio/rebalancing/models/equally_weighted.py +3 -3
  44. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
  45. wbportfolio/rebalancing/models/model_portfolio.py +9 -10
  46. wbportfolio/serializers/orders/order_proposals.py +25 -21
  47. wbportfolio/serializers/orders/orders.py +5 -2
  48. wbportfolio/serializers/positions.py +2 -2
  49. wbportfolio/serializers/rebalancing.py +1 -1
  50. wbportfolio/tests/conftest.py +6 -2
  51. wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
  52. wbportfolio/tests/models/test_imports.py +5 -3
  53. wbportfolio/tests/models/test_portfolios.py +57 -23
  54. wbportfolio/tests/models/test_products.py +11 -0
  55. wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
  56. wbportfolio/tests/rebalancing/test_models.py +3 -5
  57. wbportfolio/tests/signals.py +0 -10
  58. wbportfolio/tests/tests.py +2 -0
  59. wbportfolio/viewsets/__init__.py +7 -4
  60. wbportfolio/viewsets/assets.py +1 -215
  61. wbportfolio/viewsets/charts/__init__.py +6 -1
  62. wbportfolio/viewsets/charts/assets.py +341 -155
  63. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  64. wbportfolio/viewsets/configs/display/assets.py +6 -19
  65. wbportfolio/viewsets/configs/display/products.py +1 -1
  66. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  67. wbportfolio/viewsets/configs/menu/__init__.py +1 -0
  68. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  69. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
  70. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
  71. wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
  72. wbportfolio/viewsets/orders/order_proposals.py +47 -7
  73. wbportfolio/viewsets/orders/orders.py +31 -29
  74. wbportfolio/viewsets/portfolios.py +3 -3
  75. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
  76. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
  77. wbportfolio/viewsets/signals.py +0 -43
  78. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
  79. {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,161 @@
1
+ import logging
2
+ from datetime import datetime
3
+
4
+ from django.conf import settings
5
+ from requests import HTTPError
6
+
7
+ from wbportfolio.api_clients.ubs import UBSNeoAPIClient
8
+ from wbportfolio.pms.typing import Order
9
+
10
+ from .. import ExecutionStatus, RoutingException
11
+ from . import BaseCustodianAdapter
12
+
13
+ ASSET_CLASS_MAP = {Order.AssetType.EQUITY: "EQUITY"} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
14
+ ASSET_CLASS_MAP_INV = {
15
+ v: k for k, v in ASSET_CLASS_MAP.items()
16
+ } # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
17
+
18
+ STATUS_MAP = {
19
+ "Amend Pending": ExecutionStatus.PENDING,
20
+ "Cancel Pending": ExecutionStatus.PENDING,
21
+ "Cancelled": ExecutionStatus.PENDING,
22
+ "Complete": ExecutionStatus.PENDING,
23
+ "Complete (Order Cancelled)": ExecutionStatus.PENDING,
24
+ "Complete (Partial Fill)": ExecutionStatus.PENDING,
25
+ "In Draft": ExecutionStatus.PENDING,
26
+ "Pending Approval": ExecutionStatus.PENDING,
27
+ "Pending Execution": ExecutionStatus.PENDING,
28
+ "Rebalance Cancelled": ExecutionStatus.PENDING,
29
+ "Rebalance Cancelled (Executing partially)": ExecutionStatus.PENDING,
30
+ "Rejected": ExecutionStatus.PENDING,
31
+ "Rejection Acknowledged": ExecutionStatus.PENDING,
32
+ "Waiting for Response": ExecutionStatus.PENDING,
33
+ }
34
+ logger = logging.getLogger("oms")
35
+
36
+
37
+ def _serialize_orders(orders: list[Order], default_execution_instruction=None) -> list[dict[str, str]]:
38
+ items = []
39
+ for order in orders:
40
+ if order.refinitiv_identifier_code:
41
+ identifier_type, identifier = "RIC", order.refinitiv_identifier_code
42
+ elif order.bloomberg_ticker:
43
+ identifier_type, identifier = "BBTICKER", order.bloomberg_ticker
44
+ else:
45
+ identifier_type, identifier = "SEDOL", order.sedol
46
+ item = {
47
+ "assetClass": ASSET_CLASS_MAP[order.asset_class],
48
+ "identifierType": identifier_type,
49
+ "identifier": identifier,
50
+ "executionInstruction": order.execution_instruction
51
+ if order.execution_instruction
52
+ else default_execution_instruction,
53
+ "userElementId": str(order.id),
54
+ "tradeDate": order.trade_date.strftime("%Y-%m-%d"),
55
+ }
56
+ if order.shares:
57
+ item["sharesToTrade"] = str(order.shares)
58
+ else:
59
+ item["targetWeight"] = str(order.target_weight)
60
+ items.append(item)
61
+ return items
62
+
63
+
64
+ def _deserialize_items(items: list[dict[str, str]]):
65
+ orders = []
66
+ for item in items:
67
+ orders.append(
68
+ Order(
69
+ id=item.get("userElementId"),
70
+ asset_class=ASSET_CLASS_MAP_INV[item.get("assetClass")],
71
+ refinitiv_identifier_code=item.get(
72
+ "ric", item["identifier"] if item.get("identifierType") == "RIC" else None
73
+ ),
74
+ bloomberg_ticker=item["identifier"] if item.get("identifierType") == "BBTICKER" else None,
75
+ sedol=item["identifier"] if item.get("identifierType") == "SEDOL" else None,
76
+ trade_date=datetime.strptime(item.get("tradeDate"), "%Y-%m-%d"),
77
+ target_weight=float(item["targetWeight"]) if "targetWeight" in item else None,
78
+ shares=float(item["sharesToTrade"]) if "sharesToTrade" in item else None,
79
+ execution_instruction=item.get("executionInstruction"),
80
+ )
81
+ )
82
+ return orders
83
+
84
+
85
+ class CustodianAdapter(BaseCustodianAdapter):
86
+ client: UBSNeoAPIClient
87
+
88
+ def __init__(self, *args, raise_exception: bool = False, **kwargs):
89
+ super().__init__(*args, **kwargs)
90
+ self.raise_exception = raise_exception
91
+
92
+ def _handle_response(self, res):
93
+ logger.info(res["message"])
94
+ if errors := res.get("errors"):
95
+ logger.warning(errors)
96
+ if self.raise_exception:
97
+ raise RoutingException(errors)
98
+
99
+ def authenticate(self) -> bool:
100
+ """
101
+ Authenticate or renew tokens with the custodian API.
102
+ Raises an exception if authentication fails.
103
+ """
104
+ self.client = UBSNeoAPIClient(settings.UBS_NEO_API_TOKEN)
105
+ return True
106
+
107
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
108
+ res = self.client.get_rebalance_status_for_isin(self.isin)
109
+ self._handle_response(res)
110
+ status = res["rebalanceStatus"]
111
+ return STATUS_MAP.get(status, ExecutionStatus.UNKNOWN), status
112
+
113
+ def is_valid(self) -> bool:
114
+ """
115
+ Check whether the given isin is valid and can be rebalanced
116
+ """
117
+
118
+ try:
119
+ status_res = self.client.get_rebalance_service_status()
120
+
121
+ isin_res = self.client.get_rebalance_status_for_isin(self.isin)
122
+ self._handle_response(status_res)
123
+ self._handle_response(isin_res)
124
+ return (
125
+ status_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
126
+ and isin_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
127
+ )
128
+ except (HTTPError, KeyError) as e:
129
+ logger.warning(f"Couldn't validate adapter: {str(e)}")
130
+ return False
131
+
132
+ def submit_rebalancing(self, orders: list[Order], as_draft: bool = True) -> tuple[list[Order], str]:
133
+ """
134
+ Submit a rebalance order for the certificate.
135
+ """
136
+ items = _serialize_orders(orders, default_execution_instruction="MARKET_ON_CLOSE")
137
+ if not as_draft:
138
+ res = self.client.submit_rebalance(self.isin, items)
139
+ else:
140
+ res = self.client.save_draft(self.isin, items)
141
+ self._handle_response(res)
142
+ return _deserialize_items(res["rebalanceItems"]), res["message"]
143
+
144
+ def cancel_current_rebalancing(self) -> bool:
145
+ """
146
+ Cancel an existing rebalance order identified by ISIN.
147
+ """
148
+ try:
149
+ res = self.client.cancel_rebalance(self.isin)
150
+ self._handle_response(res)
151
+ return res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
152
+ except (HTTPError, KeyError):
153
+ return False
154
+
155
+ def get_current_rebalancing(self) -> list[Order]:
156
+ """
157
+ Fetch the current rebalance request details for a certificate.
158
+ """
159
+ res = self.client.get_current_rebalance_request(self.isin)
160
+ self._handle_response(res)
161
+ return _deserialize_items(res["rebalanceItems"])
@@ -87,7 +87,6 @@ class TradingService:
87
87
  total_target_weight
88
88
  )
89
89
  self._effective_portfolio = effective_portfolio
90
- self._target_portfolio = target_portfolio
91
90
 
92
91
  @property
93
92
  def errors(self) -> list[str]:
@@ -146,12 +145,15 @@ class TradingService:
146
145
  previous_weight = target_weight = 0
147
146
  effective_shares = target_shares = 0
148
147
  daily_return = 0
148
+ is_cash = False
149
149
  if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
150
150
  previous_weight = effective_pos.weighting
151
151
  effective_shares = effective_pos.shares
152
152
  daily_return = effective_pos.daily_return
153
+ is_cash = effective_pos.is_cash
153
154
  if target_pos := target_portfolio.positions_map.get(instrument_id, None):
154
155
  target_weight = target_pos.weighting
156
+ is_cash = target_pos.is_cash
155
157
  if target_pos.shares is not None:
156
158
  target_shares = target_pos.shares
157
159
  trade = Trade(
@@ -167,6 +169,7 @@ class TradingService:
167
169
  currency_fx_rate=Decimal(pos.currency_fx_rate),
168
170
  daily_return=Decimal(daily_return),
169
171
  portfolio_contribution=effective_portfolio.portfolio_contribution,
172
+ is_cash=is_cash,
170
173
  )
171
174
  trades.append(trade)
172
175
  return TradeBatch(trades)
wbportfolio/pms/typing.py CHANGED
@@ -1,4 +1,6 @@
1
+ import enum
1
2
  from dataclasses import asdict, dataclass, field, fields
3
+ from datetime import date
2
4
  from datetime import date as date_lib
3
5
  from decimal import Decimal
4
6
 
@@ -13,7 +15,7 @@ class Valuation:
13
15
  outstanding_shares: Decimal = Decimal(0)
14
16
 
15
17
 
16
- @dataclass(frozen=True)
18
+ @dataclass()
17
19
  class Position:
18
20
  underlying_instrument: int
19
21
  weighting: Decimal
@@ -38,6 +40,10 @@ class Position:
38
40
  volume_usd: float = None
39
41
  price: float = None
40
42
 
43
+ def __post_init__(self):
44
+ self.daily_return = round(self.daily_return, 16)
45
+ self.weighting = round(self.weighting, 8)
46
+
41
47
  def __add__(self, other):
42
48
  return Position(
43
49
  weighting=self.weighting + other.weighting,
@@ -58,6 +64,7 @@ class Portfolio:
58
64
 
59
65
  def __post_init__(self):
60
66
  positions_map = {}
67
+
61
68
  for pos in self.positions:
62
69
  if pos.underlying_instrument in positions_map:
63
70
  positions_map[pos.underlying_instrument] += pos
@@ -67,7 +74,7 @@ class Portfolio:
67
74
 
68
75
  @property
69
76
  def total_weight(self):
70
- return round(sum([pos.weighting for pos in self.positions]), 8)
77
+ return sum([pos.weighting for pos in self.positions])
71
78
 
72
79
  @property
73
80
  def total_shares(self):
@@ -75,7 +82,7 @@ class Portfolio:
75
82
 
76
83
  @property
77
84
  def portfolio_contribution(self) -> Decimal:
78
- return sum(map(lambda pos: pos.weighting * (Decimal("1") + pos.daily_return), self.positions))
85
+ return round(sum(map(lambda pos: pos.weighting * (Decimal("1") + pos.daily_return), self.positions)), 16)
79
86
 
80
87
  def to_df(self):
81
88
  return pd.DataFrame([asdict(pos) for pos in self.positions])
@@ -91,6 +98,28 @@ class Portfolio:
91
98
 
92
99
 
93
100
  @dataclass(frozen=True)
101
+ class Order:
102
+ class AssetType(enum.Enum):
103
+ EQUITY = "EQUITY"
104
+
105
+ id: int | str
106
+ trade_date: date
107
+ target_weight: float
108
+
109
+ # Instrument identifier
110
+ asset_class: AssetType
111
+ refinitiv_identifier_code: str | None = None
112
+ bloomberg_ticker: str | None = None
113
+ sedol: str | None = None
114
+
115
+ weighting: float | None = None
116
+ target_shares: float | None = None
117
+ shares: float | None = None
118
+ execution_instruction: str | None = None
119
+ comment: str = ""
120
+
121
+
122
+ @dataclass()
94
123
  class Trade:
95
124
  underlying_instrument: int
96
125
  instrument_type: int
@@ -104,9 +133,19 @@ class Trade:
104
133
  target_shares: Decimal = Decimal("0")
105
134
  daily_return: Decimal = Decimal("0")
106
135
  portfolio_contribution: Decimal = Decimal("1")
136
+ quantization_error: Decimal = Decimal("0")
137
+
107
138
  id: int | None = None
108
139
  is_cash: bool = False
109
140
 
141
+ def __post_init__(self):
142
+ self.previous_weight = round(self.previous_weight, 8)
143
+ # ensure a trade target weight cannot be lower than 0
144
+ self.target_weight = round(self.target_weight, 8)
145
+ if self.target_weight < Decimal("1e-7"):
146
+ self.target_weight = Decimal("0")
147
+ self.daily_return = round(self.daily_return, 16)
148
+
110
149
  def __add__(self, other):
111
150
  return Trade(
112
151
  underlying_instrument=self.underlying_instrument,
@@ -137,12 +176,20 @@ class Trade:
137
176
  attrs.update(kwargs)
138
177
  return Trade(**attrs)
139
178
 
179
+ def set_quantization_error(self, quantization_error: Decimal):
180
+ self.quantization_error = quantization_error
181
+ self.target_weight += quantization_error
182
+
140
183
  @property
141
184
  def effective_weight(self) -> Decimal:
142
185
  return (
143
- self.previous_weight * (round(self.daily_return, 16) + 1) / self.portfolio_contribution
144
- if self.portfolio_contribution
145
- else self.previous_weight
186
+ round(
187
+ self.previous_weight * (self.daily_return + 1) / self.portfolio_contribution
188
+ if self.portfolio_contribution
189
+ else self.previous_weight,
190
+ 8,
191
+ )
192
+ + self.quantization_error
146
193
  )
147
194
 
148
195
  @property
@@ -184,6 +231,9 @@ class TradeBatch:
184
231
 
185
232
  def __post_init__(self):
186
233
  trade_map = {}
234
+ total_effective_weight = self.total_effective_weight
235
+ if total_effective_weight and (quant_error := Decimal("1") - self.total_effective_weight):
236
+ self.largest_effective_order.set_quantization_error(quant_error)
187
237
  for trade in self.trades:
188
238
  if trade.underlying_instrument in trade_map:
189
239
  trade_map[trade.underlying_instrument] += trade
@@ -191,13 +241,17 @@ class TradeBatch:
191
241
  trade_map[trade.underlying_instrument] = trade
192
242
  object.__setattr__(self, "trades_map", trade_map)
193
243
 
244
+ @property
245
+ def largest_effective_order(self) -> Trade:
246
+ return max(self.trades, key=lambda obj: obj.previous_weight)
247
+
194
248
  @property
195
249
  def total_target_weight(self) -> Decimal:
196
- return round(sum([trade.target_weight for trade in self.trades], Decimal("0")), 8)
250
+ return sum([trade.target_weight for trade in self.trades], Decimal("0"))
197
251
 
198
252
  @property
199
253
  def total_effective_weight(self) -> Decimal:
200
- return round(sum([trade.effective_weight for trade in self.trades], Decimal("0")), 8)
254
+ return sum([trade.effective_weight for trade in self.trades], Decimal("0"))
201
255
 
202
256
  @property
203
257
  def total_abs_delta_weight(self) -> Decimal:
@@ -20,7 +20,7 @@ class CompositeRebalancing(AbstractRebalancingModel):
20
20
  """
21
21
  try:
22
22
  latest_order_proposal = self.portfolio.order_proposals.filter(
23
- status="APPROVED", trade_date__lt=self.trade_date
23
+ status="APPLIED", trade_date__lt=self.trade_date
24
24
  ).latest("trade_date")
25
25
  return {
26
26
  v["underlying_instrument"]: v["target_weight"]
@@ -24,11 +24,11 @@ class EquallyWeightedRebalancing(AbstractRebalancingModel):
24
24
 
25
25
  def get_target_portfolio(self) -> Portfolio:
26
26
  positions = []
27
- nb_assets = len(self.effective_portfolio.positions)
28
- for position in self.effective_portfolio.positions:
27
+ assets = list(filter(lambda p: not p.is_cash, self.effective_portfolio.positions))
28
+ for position in assets:
29
29
  positions.append(
30
30
  position.copy(
31
- weighting=Decimal(1 / nb_assets), date=self.trade_date, asset_valuation_date=self.trade_date
31
+ weighting=Decimal(1 / len(assets)), date=self.trade_date, asset_valuation_date=self.trade_date
32
32
  )
33
33
  )
34
34
  return Portfolio(positions)
@@ -22,7 +22,7 @@ from wbportfolio.rebalancing.decorators import register
22
22
  @register("Market Capitalization Rebalancing")
23
23
  class MarketCapitalizationRebalancing(AbstractRebalancingModel):
24
24
  TARGET_CURRENCY: str = "USD"
25
- MIN_WEIGHT: float = 10e-5 # we allow only weight of minimum 0.01%
25
+ MIN_WEIGHT: float = 1e-5 # we allow only weight of minimum 0.01%
26
26
 
27
27
  def __init__(
28
28
  self,
@@ -1,5 +1,3 @@
1
- from wbfdm.models import InstrumentPrice
2
-
3
1
  from wbportfolio.pms.typing import Portfolio
4
2
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
5
3
  from wbportfolio.rebalancing.decorators import register
@@ -9,6 +7,11 @@ from wbportfolio.rebalancing.decorators import register
9
7
  class ModelPortfolioRebalancing(AbstractRebalancingModel):
10
8
  def __init__(self, *args, **kwargs):
11
9
  super().__init__(*args, **kwargs)
10
+ self.value_date = (
11
+ self.trade_date
12
+ if self.model_portfolio.assets.filter(date=self.trade_date).exists()
13
+ else self.last_effective_date
14
+ )
12
15
 
13
16
  @property
14
17
  def model_portfolio_rel(self):
@@ -20,19 +23,15 @@ class ModelPortfolioRebalancing(AbstractRebalancingModel):
20
23
 
21
24
  @property
22
25
  def assets(self):
23
- return self.model_portfolio.get_positions(self.last_effective_date) if self.model_portfolio else []
26
+ return self.model_portfolio.get_positions(self.value_date) if self.model_portfolio else []
24
27
 
25
28
  def is_valid(self) -> bool:
26
- instruments = list(map(lambda o: o.underlying_quote, self.assets))
27
- return (
28
- len(self.assets) > 0
29
- and InstrumentPrice.objects.filter(date=self.trade_date, instrument__in=instruments).exists()
30
- )
29
+ return len(self.assets) > 0
31
30
 
32
31
  def get_target_portfolio(self) -> Portfolio:
33
32
  positions = []
34
33
  for asset in self.assets:
35
- asset.date = self.trade_date
36
- asset.asset_valuation_date = self.trade_date
34
+ asset.date = self.value_date
35
+ asset.asset_valuation_date = self.value_date
37
36
  positions.append(asset._build_dto())
38
37
  return Portfolio(positions=tuple(positions))
@@ -1,10 +1,9 @@
1
- from decimal import Decimal
2
-
3
1
  from django.contrib.messages import warning
4
2
  from django.core.exceptions import ValidationError
5
3
  from rest_framework.reverse import reverse
6
4
  from wbcore import serializers as wb_serializers
7
- from wbcore.serializers import DefaultFromView
5
+ from wbcore.contrib.directory.serializers import PersonRepresentationSerializer
6
+ from wbcore.serializers import CharField, DefaultFromView
8
7
 
9
8
  from wbportfolio.models import OrderProposal, Portfolio, RebalancingModel
10
9
 
@@ -20,31 +19,28 @@ class OrderProposalRepresentationSerializer(wb_serializers.RepresentationSeriali
20
19
 
21
20
 
22
21
  class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
22
+ _portfolio = PortfolioRepresentationSerializer(source="portfolio")
23
23
  rebalancing_model = wb_serializers.PrimaryKeyRelatedField(queryset=RebalancingModel.objects.all(), required=False)
24
24
  _rebalancing_model = RebalancingModelRepresentationSerializer(source="rebalancing_model")
25
25
  target_portfolio = wb_serializers.PrimaryKeyRelatedField(
26
26
  queryset=Portfolio.objects.all(), write_only=True, required=False
27
27
  )
28
28
  _target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
29
- total_cash_weight = wb_serializers.DecimalField(
30
- default=0,
31
- decimal_places=4,
32
- max_digits=5,
33
- write_only=True,
34
- required=False,
35
- precision=4,
36
- percent=True,
37
- label="Target Cash",
38
- help_text="Enter the desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
39
- )
40
-
41
29
  trade_date = wb_serializers.DateField(
42
30
  read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
43
31
  )
32
+ _creator = PersonRepresentationSerializer(source="creator")
33
+ _approver = PersonRepresentationSerializer(source="approver")
34
+ execution_status_repr = wb_serializers.SerializerMethodField(label="Status", field_class=CharField, read_only=True)
35
+
36
+ def get_execution_status_repr(self, obj):
37
+ repr = obj.execution_status
38
+ if obj.execution_status_detail:
39
+ repr += f" (Custodian: {obj.execution_status_detail})"
40
+ return repr
44
41
 
45
42
  def create(self, validated_data):
46
43
  target_portfolio = validated_data.pop("target_portfolio", None)
47
- total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
48
44
  rebalancing_model = validated_data.get("rebalancing_model", None)
49
45
  if request := self.context.get("request"):
50
46
  validated_data["creator"] = request.user.profile
@@ -59,9 +55,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
59
55
  )
60
56
 
61
57
  try:
62
- obj.reset_orders(
63
- target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
64
- )
58
+ obj.reset_orders(target_portfolio=target_portfolio_dto)
65
59
  except ValidationError as e:
66
60
  if request := self.context.get("request"):
67
61
  warning(request, str(e), extra_tags="auto_close=0")
@@ -70,7 +64,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
70
64
  @wb_serializers.register_only_instance_resource()
71
65
  def additional_resources(self, instance, request, user, **kwargs):
72
66
  res = {}
73
- if instance.status == OrderProposal.Status.APPROVED:
67
+ if instance.status == OrderProposal.Status.APPLIED:
74
68
  res["replay"] = reverse("wbportfolio:orderproposal-replay", args=[instance.id], request=request)
75
69
  if instance.status == OrderProposal.Status.DRAFT:
76
70
  res["reset"] = reverse("wbportfolio:orderproposal-reset", args=[instance.id], request=request)
@@ -86,18 +80,28 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
86
80
  class Meta:
87
81
  model = OrderProposal
88
82
  only_fsm_transition_on_instance = True
83
+ percent_fields = ["total_cash_weight"]
89
84
  fields = (
90
85
  "id",
91
86
  "trade_date",
87
+ "portfolio",
88
+ "_portfolio",
92
89
  "total_cash_weight",
93
90
  "comment",
94
91
  "status",
95
92
  "min_order_value",
96
- "portfolio",
97
93
  "_rebalancing_model",
98
94
  "rebalancing_model",
99
95
  "target_portfolio",
100
96
  "_target_portfolio",
97
+ "creator",
98
+ "approver",
99
+ "_creator",
100
+ "_approver",
101
+ "execution_status",
102
+ "execution_status_detail",
103
+ "execution_comment",
104
+ "execution_status_repr",
101
105
  "_additional_resources",
102
106
  )
103
107
 
@@ -42,6 +42,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
42
42
  underlying_instrument_ticker = wb_serializers.CharField(read_only=True)
43
43
  underlying_instrument_refinitiv_identifier_code = wb_serializers.CharField(read_only=True)
44
44
  underlying_instrument_instrument_type = wb_serializers.CharField(read_only=True)
45
+ underlying_instrument_exchange = wb_serializers.CharField(read_only=True)
45
46
 
46
47
  target_weight = wb_serializers.DecimalField(
47
48
  max_digits=Order.ORDER_WEIGHTING_PRECISION + 1,
@@ -83,8 +84,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
83
84
  weighting = data.get("weighting", self.instance.weighting if self.instance else Decimal(0.0))
84
85
  if (target_weight := data.pop("target_weight", None)) is not None:
85
86
  weighting = target_weight - effective_weight
86
- if (target_weight := data.pop("target_weight", None)) is not None:
87
- weighting = target_weight - effective_weight
87
+ data["desired_target_weight"] = target_weight
88
88
  if weighting >= 0:
89
89
  data["order_type"] = "BUY"
90
90
  else:
@@ -124,6 +124,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
124
124
  "underlying_instrument_ticker",
125
125
  "underlying_instrument_refinitiv_identifier_code",
126
126
  "underlying_instrument_instrument_type",
127
+ "underlying_instrument_exchange",
127
128
  "order_type",
128
129
  "comment",
129
130
  "effective_weight",
@@ -138,6 +139,8 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
138
139
  "target_total_value_fx_portfolio",
139
140
  "portfolio_currency",
140
141
  "has_warnings",
142
+ "execution_confirmed",
143
+ "execution_comment",
141
144
  )
142
145
 
143
146
 
@@ -11,8 +11,8 @@ class AggregatedAssetPositionModelSerializer(wb_serializers.ModelSerializer):
11
11
  sum_total_value = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=4)
12
12
  weighting = wb_serializers.DecimalField(
13
13
  read_only=True,
14
- max_digits=6,
15
- decimal_places=4,
14
+ max_digits=9,
15
+ decimal_places=8,
16
16
  percent=True,
17
17
  decorators=[{"position": "right", "value": "%"}],
18
18
  )
@@ -49,7 +49,7 @@ class RebalancerModelSerializer(wb_serializers.ModelSerializer):
49
49
  "computed_str",
50
50
  "_rebalancing_model",
51
51
  "rebalancing_model",
52
- "approve_order_proposal_automatically",
52
+ "apply_order_proposal_automatically",
53
53
  "activation_date",
54
54
  "frequency",
55
55
  "frequency_repr",
@@ -70,7 +70,7 @@ from wbportfolio.factories import (
70
70
  RebalancerFactory,
71
71
  RebalancingModelFactory
72
72
  )
73
-
73
+ from wbreport.factories import ReportFactory, ReportAssetFactory, ReportVersionFactory, ReportClassFactory, ReportCategoryFactory
74
74
  from wbcore.tests.conftest import * # isort:skip
75
75
 
76
76
  register(AccountFactory)
@@ -147,7 +147,11 @@ register(DailyPortfolioCashFlowFactory)
147
147
 
148
148
  register(AccountReconciliationFactory)
149
149
  register(AccountReconciliationLineFactory)
150
-
150
+ register(ReportFactory)
151
+ register(ReportAssetFactory)
152
+ register(ReportVersionFactory)
153
+ register(ReportClassFactory)
154
+ register(ReportCategoryFactory)
151
155
 
152
156
  pre_migrate.connect(app_pre_migration, sender=apps.get_app_config("wbportfolio"))
153
157
  from .signals import * # noqa: F401