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.
- wbportfolio/admin/orders/order_proposals.py +2 -0
- wbportfolio/admin/orders/orders.py +2 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/api_clients/ubs.py +23 -11
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +8 -3
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/orders/order_proposals.py +3 -6
- wbportfolio/filters/portfolios.py +18 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/handlers/asset_position.py +9 -5
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +2 -2
- wbportfolio/import_export/handlers/trade.py +4 -4
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/resources/trades.py +1 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +7 -3
- wbportfolio/models/builder.py +25 -5
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
- wbportfolio/models/orders/order_proposals.py +620 -490
- wbportfolio/models/orders/orders.py +237 -75
- wbportfolio/models/portfolio.py +79 -18
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +4 -1
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/claim.py +6 -5
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/trades.py +4 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +16 -0
- wbportfolio/order_routing/adapters/__init__.py +14 -6
- wbportfolio/order_routing/adapters/ubs.py +104 -70
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +115 -103
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/orders/order_proposals.py +6 -2
- wbportfolio/serializers/orders/orders.py +119 -26
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
- wbportfolio/tests/models/test_portfolios.py +9 -9
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
- wbportfolio/tests/rebalancing/test_models.py +2 -2
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +1 -1
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
- wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
- wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
- wbportfolio/viewsets/orders/order_proposals.py +92 -21
- wbportfolio/viewsets/orders/orders.py +79 -26
- wbportfolio/viewsets/portfolios.py +24 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/fdm/tasks.py +0 -42
- wbportfolio/models/orders/routing.py +0 -54
- wbportfolio/pms/trading/handler.py +0 -211
- /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
wbportfolio/permissions.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
positions.append(position)
|
|
290
|
-
return Portfolio(tuple(positions))
|
|
293
|
+
trades.append(trade)
|
|
294
|
+
return TradeBatch(trades)
|
|
291
295
|
|
|
292
|
-
def
|
|
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
|
|
304
|
+
Normalize the instantiate portfolio so that the sum of the cash position equals to the new target cash position
|
|
295
305
|
"""
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
for
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
|
67
|
+
for instrument_id in portfolio.positions_map.keys():
|
|
68
68
|
instrument = Instrument.objects.get(id=instrument_id)
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|