wbportfolio 1.55.8__py2.py3-none-any.whl → 1.59.4__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 (128) hide show
  1. wbportfolio/admin/orders/order_proposals.py +2 -0
  2. wbportfolio/admin/orders/orders.py +2 -0
  3. wbportfolio/admin/portfolio.py +11 -5
  4. wbportfolio/api_clients/ubs.py +23 -11
  5. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  6. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  7. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  8. wbportfolio/contrib/company_portfolio/models.py +69 -39
  9. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  10. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  11. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  12. wbportfolio/factories/assets.py +1 -1
  13. wbportfolio/factories/orders/order_proposals.py +3 -1
  14. wbportfolio/factories/orders/orders.py +8 -3
  15. wbportfolio/factories/product_groups.py +3 -3
  16. wbportfolio/factories/products.py +3 -3
  17. wbportfolio/filters/assets.py +0 -1
  18. wbportfolio/filters/orders/order_proposals.py +3 -6
  19. wbportfolio/filters/portfolios.py +18 -1
  20. wbportfolio/filters/positions.py +0 -1
  21. wbportfolio/filters/transactions/fees.py +0 -2
  22. wbportfolio/filters/transactions/trades.py +0 -1
  23. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  24. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  25. wbportfolio/import_export/handlers/asset_position.py +9 -5
  26. wbportfolio/import_export/handlers/dividend.py +1 -1
  27. wbportfolio/import_export/handlers/fees.py +2 -2
  28. wbportfolio/import_export/handlers/trade.py +4 -4
  29. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  30. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  31. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  32. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  33. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  34. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  35. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  36. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  37. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  38. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  39. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  40. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  41. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  42. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  43. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  44. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  45. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  46. wbportfolio/import_export/resources/trades.py +1 -1
  47. wbportfolio/import_export/utils.py +3 -1
  48. wbportfolio/metric/backends/base.py +2 -2
  49. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  50. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  51. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  52. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  53. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  54. wbportfolio/models/adjustments.py +1 -1
  55. wbportfolio/models/asset.py +7 -3
  56. wbportfolio/models/builder.py +25 -5
  57. wbportfolio/models/custodians.py +3 -3
  58. wbportfolio/models/exceptions.py +1 -1
  59. wbportfolio/models/graphs/portfolio.py +1 -1
  60. wbportfolio/models/graphs/utils.py +11 -11
  61. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  62. wbportfolio/models/orders/order_proposals.py +620 -490
  63. wbportfolio/models/orders/orders.py +237 -75
  64. wbportfolio/models/portfolio.py +79 -18
  65. wbportfolio/models/portfolio_relationship.py +6 -0
  66. wbportfolio/models/products.py +3 -0
  67. wbportfolio/models/rebalancing.py +4 -1
  68. wbportfolio/models/roles.py +4 -10
  69. wbportfolio/models/transactions/claim.py +6 -5
  70. wbportfolio/models/transactions/dividends.py +1 -0
  71. wbportfolio/models/transactions/trades.py +4 -0
  72. wbportfolio/models/transactions/transactions.py +16 -4
  73. wbportfolio/models/utils.py +100 -1
  74. wbportfolio/order_routing/__init__.py +16 -0
  75. wbportfolio/order_routing/adapters/__init__.py +14 -6
  76. wbportfolio/order_routing/adapters/ubs.py +104 -70
  77. wbportfolio/order_routing/router.py +33 -0
  78. wbportfolio/order_routing/tests/test_router.py +110 -0
  79. wbportfolio/permissions.py +7 -0
  80. wbportfolio/pms/trading/__init__.py +0 -1
  81. wbportfolio/pms/trading/optimizer.py +61 -0
  82. wbportfolio/pms/typing.py +115 -103
  83. wbportfolio/rebalancing/models/composite.py +1 -1
  84. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  85. wbportfolio/risk_management/backends/__init__.py +1 -0
  86. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  87. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  88. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  89. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  90. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  91. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  92. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  93. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  94. wbportfolio/serializers/orders/order_proposals.py +6 -2
  95. wbportfolio/serializers/orders/orders.py +119 -26
  96. wbportfolio/serializers/transactions/claim.py +2 -2
  97. wbportfolio/tasks.py +42 -4
  98. wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
  99. wbportfolio/tests/models/test_portfolios.py +9 -9
  100. wbportfolio/tests/models/test_splits.py +1 -6
  101. wbportfolio/tests/models/test_utils.py +140 -0
  102. wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
  103. wbportfolio/tests/rebalancing/test_models.py +2 -2
  104. wbportfolio/tests/viewsets/test_products.py +1 -0
  105. wbportfolio/urls.py +1 -1
  106. wbportfolio/viewsets/charts/assets.py +8 -4
  107. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  108. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  109. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  110. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  111. wbportfolio/viewsets/esg.py +3 -5
  112. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
  113. wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
  114. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
  115. wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
  116. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
  117. wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
  118. wbportfolio/viewsets/orders/order_proposals.py +92 -21
  119. wbportfolio/viewsets/orders/orders.py +79 -26
  120. wbportfolio/viewsets/portfolios.py +24 -0
  121. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
  122. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
  123. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  124. wbportfolio/fdm/tasks.py +0 -42
  125. wbportfolio/models/orders/routing.py +0 -54
  126. wbportfolio/pms/trading/handler.py +0 -211
  127. /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
  128. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,5 @@
1
+ from rest_framework.permissions import IsAuthenticated
2
+
1
3
  from wbportfolio.models import PortfolioRole
2
4
 
3
5
 
@@ -11,3 +13,8 @@ def is_portfolio_manager(request):
11
13
 
12
14
  def is_analyst(request):
13
15
  return PortfolioRole.is_analyst(request.user.profile)
16
+
17
+
18
+ class IsPortfolioManager(IsAuthenticated):
19
+ def has_permission(self, request, view):
20
+ return is_portfolio_manager(request)
@@ -1 +0,0 @@
1
- from .handler import TradingService
@@ -0,0 +1,61 @@
1
+ import math
2
+
3
+ import cvxpy as cp
4
+ import numpy as np
5
+
6
+ from wbportfolio.pms.typing import TradeBatch
7
+
8
+
9
+ class TradeShareOptimizer:
10
+ def __init__(self, batch: TradeBatch, portfolio_total_value: float):
11
+ self.batch = batch
12
+ self.portfolio_total_value = portfolio_total_value
13
+
14
+ def optimize(self, target_cash: float = 0.99):
15
+ try:
16
+ return self.optimize_trade_share(target_cash)
17
+ except ValueError:
18
+ return self.floor_trade_share()
19
+
20
+ def optimize_trade_share(self, target_cash: float = 0.01):
21
+ prices_fx_portfolio = np.array([trade.price_fx_portfolio for trade in self.batch.trades])
22
+ target_allocs = np.array([trade.target_weight for trade in self.batch.trades])
23
+
24
+ # Decision variable: number of shares (integers)
25
+ shares = cp.Variable(len(prices_fx_portfolio), integer=True)
26
+
27
+ # Calculate portfolio values
28
+ portfolio_values = cp.multiply(shares, prices_fx_portfolio)
29
+
30
+ # Target values based on allocations
31
+ target_values = self.portfolio_total_value * target_allocs
32
+
33
+ # Objective: minimize absolute deviation from target values
34
+ objective = cp.Minimize(cp.sum(cp.abs(portfolio_values - target_values)))
35
+
36
+ # Constraints
37
+ constraints = [
38
+ shares >= 0, # No short selling
39
+ cp.sum(portfolio_values) <= self.portfolio_total_value, # Don't exceed budget
40
+ cp.sum(portfolio_values) >= (1.0 - target_cash) * self.portfolio_total_value, # Use at least 99% of budget
41
+ ]
42
+
43
+ # Solve
44
+ problem = cp.Problem(objective, constraints)
45
+ problem.solve(solver=cp.CBC)
46
+
47
+ if problem != "optimal":
48
+ raise ValueError(f"Optimization failed: {problem.status}")
49
+
50
+ shares_result = shares.value.astype(int)
51
+ return TradeBatch(
52
+ [
53
+ trade.normalize_target(target_shares=shares_result[index])
54
+ for index, trade in enumerate(self.batch.trades)
55
+ ]
56
+ )
57
+
58
+ def floor_trade_share(self):
59
+ return TradeBatch(
60
+ [trade.normalize_target(target_shares=math.floor(trade.target_shares)) for trade in self.batch.trades]
61
+ )
wbportfolio/pms/typing.py CHANGED
@@ -3,10 +3,13 @@ from dataclasses import asdict, dataclass, field, fields
3
3
  from datetime import date
4
4
  from datetime import date as date_lib
5
5
  from decimal import Decimal
6
+ from typing import Self
6
7
 
7
8
  import pandas as pd
8
9
  from django.core.exceptions import ValidationError
9
10
 
11
+ from wbportfolio.order_routing import ExecutionInstruction
12
+
10
13
 
11
14
  @dataclass(frozen=True)
12
15
  class Valuation:
@@ -57,50 +60,11 @@ class Position:
57
60
  return Position(**attrs)
58
61
 
59
62
 
60
- @dataclass(frozen=True)
61
- class Portfolio:
62
- positions: list[Position] | list
63
- positions_map: dict[int, Position] = field(init=False, repr=False)
64
-
65
- def __post_init__(self):
66
- positions_map = {}
67
-
68
- for pos in self.positions:
69
- if pos.underlying_instrument in positions_map:
70
- positions_map[pos.underlying_instrument] += pos
71
- else:
72
- positions_map[pos.underlying_instrument] = pos
73
- object.__setattr__(self, "positions_map", positions_map)
74
-
75
- @property
76
- def total_weight(self):
77
- return sum([pos.weighting for pos in self.positions])
78
-
79
- @property
80
- def total_shares(self):
81
- return sum([pos.target_shares for pos in self.positions if pos.target_shares is not None])
82
-
83
- @property
84
- def portfolio_contribution(self) -> Decimal:
85
- return round(sum(map(lambda pos: pos.weighting * (Decimal("1") + pos.daily_return), self.positions)), 16)
86
-
87
- def to_df(self):
88
- return pd.DataFrame([asdict(pos) for pos in self.positions])
89
-
90
- def to_dict(self) -> dict[int, Decimal]:
91
- return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
92
-
93
- def __len__(self):
94
- return len(self.positions)
95
-
96
- def __bool__(self):
97
- return len(self.positions) > 0
98
-
99
-
100
63
  @dataclass(frozen=True)
101
64
  class Order:
102
65
  class AssetType(enum.Enum):
103
66
  EQUITY = "EQUITY"
67
+ AMERICAN_DEPOSITORY_RECEIPT = "AMERICAN_DEPOSITORY_RECEIPT"
104
68
 
105
69
  id: int | str
106
70
  trade_date: date
@@ -115,7 +79,9 @@ class Order:
115
79
  weighting: float | None = None
116
80
  target_shares: float | None = None
117
81
  shares: float | None = None
118
- execution_instruction: str | None = None
82
+ execution_price: float | None = None
83
+ execution_instruction: ExecutionInstruction = ExecutionInstruction.MARKET_ON_CLOSE.value
84
+ execution_instruction_parameters: dict | None = None
119
85
  comment: str = ""
120
86
 
121
87
 
@@ -126,47 +92,43 @@ class Trade:
126
92
  currency: int
127
93
  date: date_lib
128
94
  price: Decimal
129
- previous_weight: Decimal
95
+ effective_weight: Decimal
130
96
  target_weight: Decimal
131
97
  currency_fx_rate: Decimal = Decimal("1")
132
98
  effective_shares: Decimal = Decimal("0")
133
99
  target_shares: Decimal = Decimal("0")
134
100
  daily_return: Decimal = Decimal("0")
135
- portfolio_contribution: Decimal = Decimal("1")
136
- quantization_error: Decimal = Decimal("0")
101
+ effective_quantization_error: Decimal = Decimal("0")
102
+ target_quantization_error: Decimal = Decimal("0")
137
103
 
138
104
  id: int | None = None
139
105
  is_cash: bool = False
140
106
 
141
107
  def __post_init__(self):
142
- self.previous_weight = round(self.previous_weight, 8)
108
+ self.effective_weight = round(self.effective_weight, 8)
143
109
  # 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")
110
+ self.target_weight = max(round(self.target_weight, 8), Decimal("0"))
147
111
  self.daily_return = round(self.daily_return, 16)
148
112
 
149
113
  def __add__(self, other):
150
114
  return Trade(
151
115
  underlying_instrument=self.underlying_instrument,
152
- previous_weight=self.previous_weight,
116
+ effective_weight=self.effective_weight,
153
117
  target_weight=self.target_weight + other.target_weight,
154
118
  effective_shares=self.effective_shares,
155
119
  target_shares=self.target_shares + other.target_shares,
156
120
  daily_return=self.daily_return,
157
- portfolio_contribution=self.portfolio_contribution,
158
121
  **{
159
122
  f.name: getattr(self, f.name)
160
123
  for f in fields(Trade)
161
124
  if f.name
162
125
  not in [
163
- "previous_weight",
126
+ "effective_weight",
164
127
  "target_weight",
165
128
  "effective_shares",
166
129
  "target_shares",
167
130
  "underlying_instrument",
168
131
  "daily_return",
169
- "portfolio_contribution",
170
132
  ]
171
133
  },
172
134
  )
@@ -176,25 +138,9 @@ class Trade:
176
138
  attrs.update(kwargs)
177
139
  return Trade(**attrs)
178
140
 
179
- def set_quantization_error(self, quantization_error: Decimal):
180
- self.quantization_error = quantization_error
181
- self.target_weight += quantization_error
182
-
183
- @property
184
- def effective_weight(self) -> Decimal:
185
- return (
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
193
- )
194
-
195
141
  @property
196
142
  def delta_weight(self) -> Decimal:
197
- return self.target_weight - self.effective_weight
143
+ return (self.target_weight + self.target_quantization_error) - self.effective_weight
198
144
 
199
145
  @property
200
146
  def delta_shares(self) -> Decimal:
@@ -231,9 +177,10 @@ class TradeBatch:
231
177
 
232
178
  def __post_init__(self):
233
179
  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)
180
+ if self.total_effective_weight and (quant_error := Decimal("1") - self.total_effective_weight):
181
+ self.largest_effective_order.effective_quantization_error = quant_error
182
+ if self.total_target_weight and (quant_error := Decimal("1") - self.total_target_weight):
183
+ self.largest_effective_order.target_quantization_error = quant_error
237
184
  for trade in self.trades:
238
185
  if trade.underlying_instrument in trade_map:
239
186
  trade_map[trade.underlying_instrument] += trade
@@ -243,7 +190,7 @@ class TradeBatch:
243
190
 
244
191
  @property
245
192
  def largest_effective_order(self) -> Trade:
246
- return max(self.trades, key=lambda obj: obj.previous_weight)
193
+ return max(self.trades, key=lambda obj: obj.effective_weight)
247
194
 
248
195
  @property
249
196
  def total_target_weight(self) -> Decimal:
@@ -263,41 +210,106 @@ class TradeBatch:
263
210
  def __len__(self):
264
211
  return len(self.trades)
265
212
 
213
+ def __iter__(self):
214
+ return iter(self.trades_map.values())
215
+
266
216
  def validate(self):
267
217
  if round(float(self.total_target_weight), 4) != 1: # we do that to remove decimal over precision
268
218
  raise ValidationError(f"Total Weight cannot be different than 1 ({float(self.total_target_weight)})")
269
219
 
270
- def convert_to_portfolio(self, use_effective: bool = False, *extra_positions):
271
- positions = []
272
- for instrument, trade in self.trades_map.items():
273
- positions.append(
274
- Position(
275
- underlying_instrument=trade.underlying_instrument,
276
- instrument_type=trade.instrument_type,
277
- weighting=trade.target_weight if not use_effective else trade.previous_weight,
278
- daily_return=trade.daily_return if use_effective else Decimal("0"),
279
- shares=trade.target_shares,
280
- currency=trade.currency,
281
- date=trade.date,
282
- is_cash=trade.is_cash,
283
- price=trade.price,
284
- currency_fx_rate=trade.currency_fx_rate,
285
- )
220
+
221
+ @dataclass(frozen=True)
222
+ class Portfolio:
223
+ positions: list[Position] | list
224
+ positions_map: dict[int, Position] = field(init=False, repr=False)
225
+
226
+ def __post_init__(self):
227
+ positions_map = {}
228
+
229
+ for pos in self.positions:
230
+ if pos.underlying_instrument in positions_map:
231
+ positions_map[pos.underlying_instrument] += pos
232
+ else:
233
+ positions_map[pos.underlying_instrument] = pos
234
+ object.__setattr__(self, "positions_map", positions_map)
235
+
236
+ @property
237
+ def total_weight(self):
238
+ return sum([pos.weighting for pos in self.positions])
239
+
240
+ @property
241
+ def total_shares(self):
242
+ return sum([pos.target_shares for pos in self.positions if pos.target_shares is not None])
243
+
244
+ def to_df(self, exclude_cash: bool = False) -> pd.DataFrame:
245
+ return pd.DataFrame([asdict(pos) for pos in self.positions if not exclude_cash or not pos.is_cash])
246
+
247
+ def to_dict(self) -> dict[int, Decimal]:
248
+ return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
249
+
250
+ def get_orders(self, target_portfolio: Self) -> TradeBatch:
251
+ instruments = self.positions_map.copy()
252
+ instruments.update(target_portfolio.positions_map)
253
+
254
+ trades: list[Trade] = []
255
+ for instrument_id, pos in instruments.items():
256
+ effective_weight = target_weight = 0
257
+ effective_shares = target_shares = 0
258
+ daily_return = 0
259
+ price = Decimal("0")
260
+ is_cash = False
261
+ trade_date = None
262
+ if effective_pos := self.positions_map.get(instrument_id, None):
263
+ effective_weight = effective_pos.weighting
264
+ effective_shares = effective_pos.shares
265
+ daily_return = effective_pos.daily_return
266
+ is_cash = effective_pos.is_cash
267
+ price = effective_pos.price
268
+ trade_date = effective_pos.date
269
+
270
+ if target_pos := target_portfolio.positions_map.get(instrument_id, None):
271
+ target_weight = target_pos.weighting
272
+ is_cash = target_pos.is_cash
273
+ if target_pos.shares is not None:
274
+ target_shares = target_pos.shares
275
+ if target_pos.price:
276
+ price = target_pos.price
277
+ trade_date = target_pos.date
278
+
279
+ trade = Trade(
280
+ underlying_instrument=instrument_id,
281
+ effective_weight=effective_weight,
282
+ target_weight=target_weight,
283
+ effective_shares=effective_shares,
284
+ target_shares=target_shares,
285
+ date=trade_date,
286
+ instrument_type=pos.instrument_type,
287
+ currency=pos.currency,
288
+ price=Decimal(price) if price else Decimal("0"),
289
+ currency_fx_rate=Decimal(pos.currency_fx_rate),
290
+ daily_return=Decimal(daily_return),
291
+ is_cash=is_cash,
286
292
  )
287
- for position in extra_positions:
288
- if position.weighting:
289
- positions.append(position)
290
- return Portfolio(tuple(positions))
293
+ trades.append(trade)
294
+ return TradeBatch(trades)
291
295
 
292
- def normalize(self, total_target_weight: Decimal = Decimal("1.0")):
296
+ def __len__(self):
297
+ return len(self.positions)
298
+
299
+ def __bool__(self):
300
+ return len(self.positions) > 0
301
+
302
+ def normalize_cash(self, target_cash_weight: Decimal):
293
303
  """
294
- Normalize the instantiate trades batch so that the target weight is 100%
304
+ Normalize the instantiate portfolio so that the sum of the cash position equals to the new target cash position
295
305
  """
296
- normalization_factor = (
297
- total_target_weight / self.total_target_weight if self.total_target_weight else Decimal("0.0")
298
- )
299
- normalized_trades = []
300
- for trade in self.trades:
301
- normalized_trades.append(trade.normalize_target(normalization_factor))
302
- tb = TradeBatch(normalized_trades)
303
- return tb
306
+ positions = list(filter(lambda pos: not pos.is_cash, self.positions))
307
+ cash_position = next(filter(lambda pos: pos.is_cash, self.positions), None)
308
+ total_non_cash_weight = sum(map(lambda pos: pos.weighting, positions))
309
+ target_weight = Decimal("1") - target_cash_weight
310
+ for pos in positions:
311
+ pos.weighting = pos.weighting * target_weight / total_non_cash_weight
312
+ if cash_position:
313
+ cash_position.weighting = target_cash_weight
314
+ positions.append(cash_position)
315
+ return Portfolio(positions)
@@ -20,7 +20,7 @@ class CompositeRebalancing(AbstractRebalancingModel):
20
20
  """
21
21
  try:
22
22
  latest_order_proposal = self.portfolio.order_proposals.filter(
23
- status="APPLIED", trade_date__lt=self.trade_date
23
+ status="CONFIRMED", trade_date__lt=self.trade_date
24
24
  ).latest("trade_date")
25
25
  return {
26
26
  v["underlying_instrument"]: v["target_weight"]
@@ -108,11 +108,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
108
108
  return df.any()
109
109
  else:
110
110
  if missing_exchanges.exists():
111
- setattr(
112
- self,
113
- "_validation_errors",
114
- f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
115
- )
111
+ self._validation_errors = f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}"
116
112
  return df.all()
117
113
  return False
118
114
 
@@ -9,3 +9,4 @@ from .ucits_portfolio import *
9
9
  from .accounts import *
10
10
  from .product_integrity import *
11
11
  from .liquidity_risk import *
12
+ from .esg_aggregation_portfolio import *
@@ -2,7 +2,7 @@ from typing import Generator
2
2
 
3
3
  from wbcompliance.models.risk_management import backend
4
4
  from wbcompliance.models.risk_management.dispatch import register
5
- from wbcompliance.models.risk_management.rules import RiskIncidentType
5
+ from wbcompliance.models.risk_management.incidents import RiskIncidentType
6
6
  from wbcore import serializers as wb_serializers
7
7
  from wbfdm.enums import ESGControveryFlag
8
8
  from wbfdm.models import Instrument
@@ -44,7 +44,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
44
44
  return RuleBackendSerializer
45
45
 
46
46
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
47
- for instrument_id, weight in portfolio.positions_map.items():
47
+ for instrument_id in portfolio.positions_map.keys():
48
48
  instrument = Instrument.objects.get(id=instrument_id)
49
49
  if (
50
50
  controversies := Controversy.objects.filter(
@@ -0,0 +1,64 @@
1
+ from typing import Generator
2
+
3
+ from wbcompliance.models.risk_management import backend
4
+ from wbcompliance.models.risk_management.dispatch import register
5
+ from wbcore import serializers as wb_serializers
6
+ from wbfdm.analysis.esg.enums import ESGAggregation
7
+ from wbfdm.analysis.esg.esg_analysis import DataLoader
8
+ from wbfdm.models import Instrument
9
+
10
+ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
11
+
12
+ from .mixins import ActivePortfolioRelationshipMixin
13
+
14
+
15
+ @register("ESG Aggregation Portfolio Rule Backend", rule_group_key="portfolio")
16
+ class RuleBackend(ActivePortfolioRelationshipMixin):
17
+ def __init__(self, *args, **kwargs):
18
+ super().__init__(*args, **kwargs)
19
+ self.esg_aggregation = ESGAggregation[self.esg_aggregation]
20
+
21
+ @classmethod
22
+ def get_serializer_class(cls) -> wb_serializers.Serializer:
23
+ class RuleBackendSerializer(wb_serializers.Serializer):
24
+ esg_aggregation = wb_serializers.ChoiceField(
25
+ choices=ESGAggregation.choices(),
26
+ default=ESGAggregation.GHG_EMISSIONS_SCOPE_1.name,
27
+ )
28
+
29
+ @classmethod
30
+ def get_parameter_fields(cls):
31
+ return [
32
+ "esg_aggregation",
33
+ ]
34
+
35
+ return RuleBackendSerializer
36
+
37
+ def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
38
+ esg_data = self.esg_aggregation.get_esg_data(Instrument.objects.filter(id__in=portfolio.positions_map.keys()))
39
+ df = portfolio.to_df(exclude_cash=True)
40
+ df["total_value"] = (df["price"] * df["shares"] * df["currency_fx_rate"]).astype(float)
41
+ df = df[["total_value", "weighting", "underlying_instrument"]].set_index("underlying_instrument")
42
+ df["weighting"] = df["weighting"] / df["weighting"].sum()
43
+ dataloader = DataLoader(
44
+ df["weighting"].astype(float), esg_data, self.evaluation_date, total_value_fx_usd=df["total_value"]
45
+ )
46
+ metrics = dataloader.compute(self.esg_aggregation)
47
+ for threshold in self.thresholds:
48
+ numerical_range = threshold.numerical_range
49
+ incident_df = metrics[(metrics >= numerical_range[0]) & (metrics < numerical_range[1])]
50
+ for instrument_id, metric in incident_df.to_dict().items():
51
+ instrument = Instrument.objects.get(id=instrument_id)
52
+ breached_value = metric
53
+
54
+ if metric < 0:
55
+ breached_value = f'<span style="color:red">{breached_value}</span>'
56
+ else:
57
+ breached_value = f'<span style="color:green">{breached_value}</span>'
58
+ yield backend.IncidentResult(
59
+ breached_object=instrument,
60
+ breached_object_repr=str(instrument),
61
+ breached_value=breached_value,
62
+ report_details={"Aggregation": self.esg_aggregation.value},
63
+ severity=threshold.severity,
64
+ )
@@ -3,7 +3,7 @@ from typing import Generator
3
3
  from django.db import models
4
4
  from wbcompliance.models.risk_management import backend
5
5
  from wbcompliance.models.risk_management.dispatch import register
6
- from wbcompliance.models.risk_management.rules import RiskIncidentType
6
+ from wbcompliance.models.risk_management.incidents import RiskIncidentType
7
7
  from wbcore import serializers as wb_serializers
8
8
  from wbcore.contrib.currency.models import Currency
9
9
  from wbcore.contrib.currency.serializers import CurrencyRepresentationSerializer
@@ -160,7 +160,7 @@ class RuleBackend(
160
160
 
161
161
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
162
162
  if not (df := self._filter_df(portfolio.to_df())).empty:
163
- df = df[[self.group_by.value, self.field.value]].groupby(self.group_by.value).sum().astype(float)
163
+ df = df[[self.group_by.value, self.field.value]].dropna().groupby(self.group_by.value).sum().astype(float)
164
164
  for threshold in self.thresholds:
165
165
  numerical_range = threshold.numerical_range
166
166
  incident_df = df[
@@ -180,7 +180,7 @@ class RuleBackend(
180
180
  breached_value = f'<span style="color:green">{breached_value}</span>'
181
181
  yield backend.IncidentResult(
182
182
  breached_object=obj,
183
- breached_object_repr=obj_repr,
183
+ breached_object_repr=str(obj_repr),
184
184
  breached_value=breached_value,
185
185
  report_details=self.report_details,
186
186
  severity=severity,
@@ -218,7 +218,7 @@ class RuleBackend(
218
218
  obj = Instrument.objects.get(id=pivot_object_id)
219
219
  return obj, str(obj)
220
220
  case self.GroupbyChoices.ASSET_TYPE:
221
- return None, InstrumentType.objects.get(id=pivot_object_id)
221
+ return None, InstrumentType.objects.get(id=pivot_object_id).name
222
222
  case self.GroupbyChoices.CASH:
223
223
  return None, "Cash"
224
224
  case self.GroupbyChoices.COUNTRY:
@@ -64,10 +64,10 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
64
64
  return RuleBackendSerializer
65
65
 
66
66
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
67
- for instrument_id, weight in portfolio.positions_map.items():
67
+ for instrument_id in portfolio.positions_map.keys():
68
68
  instrument = Instrument.objects.get(id=instrument_id)
69
- relationships = self.instruments_relationship.filter(instrument=instrument, validated=True)
70
-
69
+ ancestors = instrument.get_ancestors(include_self=True)
70
+ relationships = self.instruments_relationship.filter(instrument__in=ancestors, validated=True)
71
71
  if self.exclude and relationships.exists():
72
72
  report_details = {
73
73
  "Instrument Lists": ", ".join(relationships.values_list("instrument_list__name", flat=True)),
@@ -0,0 +1,49 @@
1
+ from unittest.mock import patch
2
+
3
+ import pandas as pd
4
+ import pytest
5
+ from faker import Faker
6
+ from psycopg.types.range import NumericRange
7
+ from wbcompliance.factories.risk_management import RuleThresholdFactory
8
+ from wbfdm.analysis.esg.esg_analysis import DataLoader
9
+
10
+ from wbportfolio.risk_management.backends.esg_aggregation_portfolio import (
11
+ RuleBackend as ESGAggregationPortfolioBackend,
12
+ )
13
+ from wbportfolio.tests.models.utils import PortfolioTestMixin
14
+
15
+ fake = Faker()
16
+
17
+
18
+ @pytest.mark.django_db
19
+ class TestEsgAggregationPortfolioRuleModel(PortfolioTestMixin):
20
+ @patch.object(DataLoader, "compute")
21
+ def test_eval(
22
+ self,
23
+ mock_fct,
24
+ weekday,
25
+ asset_position_factory,
26
+ portfolio,
27
+ ):
28
+ parameters = {"esg_aggregation": "GHG_EMISSIONS_SCOPE_1"}
29
+ backend = ESGAggregationPortfolioBackend(
30
+ weekday,
31
+ portfolio,
32
+ parameters,
33
+ [RuleThresholdFactory.create(range=NumericRange(lower=0.02, upper=0.03))], # type: ignore
34
+ )
35
+
36
+ a1 = asset_position_factory.create(
37
+ date=weekday,
38
+ portfolio=portfolio,
39
+ ) # Breached position
40
+
41
+ a2 = asset_position_factory.create(
42
+ date=weekday,
43
+ portfolio=portfolio,
44
+ )
45
+ mock_fct.return_value = pd.Series(index=[a1.underlying_quote.id, a2.underlying_quote.id], data=[0.01, 0.025])
46
+ incidents = list(backend.check_rule())
47
+ assert len(incidents) == 1
48
+ incident = incidents[0]
49
+ assert incident.breached_object == a2.underlying_quote
@@ -138,5 +138,5 @@ class TestExposurePortfolioRuleModel(PortfolioTestMixin):
138
138
  incidents = list(exposure_portfolio_backend.check_rule())
139
139
  assert len(incidents) == 1
140
140
  incident = incidents[0]
141
- assert incident.breached_object_repr == i1.instrument_type
141
+ assert incident.breached_object_repr == i1.instrument_type.name
142
142
  assert incident.breached_value == '<span style="color:green">+5.00%</span>'
@@ -92,7 +92,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
92
92
  date=weekday, net_value=500, calculated=False, instrument=benchmark
93
93
  )
94
94
  RelatedInstrumentThroughModel.objects.create(instrument=product, related_instrument=benchmark, is_primary=True)
95
- setattr(stop_loss_instrument_backend, "dynamic_benchmark_type", "PRIMARY_BENCHMARK")
95
+ stop_loss_instrument_backend.dynamic_benchmark_type = "PRIMARY_BENCHMARK"
96
96
 
97
97
  res = list(stop_loss_instrument_backend.check_rule())
98
98
  assert len(res) == 0
@@ -105,7 +105,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
105
105
  assert len(res) == 1
106
106
  assert res[0].breached_object.id == product.id
107
107
 
108
- setattr(stop_loss_instrument_backend, "static_benchmark", benchmark)
108
+ stop_loss_instrument_backend.static_benchmark = benchmark
109
109
  res = list(stop_loss_instrument_backend.check_rule())
110
110
  assert len(res) == 1
111
111
  assert res[0].breached_object.id == product.id
@@ -119,7 +119,7 @@ class TestStopLossPortfolioRuleModel(PortfolioTestMixin):
119
119
  res = list(stop_loss_portfolio_backend.check_rule())
120
120
  assert len(res) == 0
121
121
 
122
- setattr(stop_loss_portfolio_backend, "static_benchmark", benchmark)
122
+ stop_loss_portfolio_backend.static_benchmark = benchmark
123
123
  res = list(stop_loss_portfolio_backend.check_rule())
124
124
  assert len(res) == 1
125
125
  assert res[0].breached_object.id == instrument.id