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
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# TBD: Should this stay a service or should we extend the order proposal model (fat model approach) ?
|
|
2
|
-
from contextlib import suppress
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
4
|
-
|
|
5
|
-
from django.conf import settings
|
|
6
|
-
from django.core.exceptions import ObjectDoesNotExist
|
|
7
|
-
|
|
8
|
-
from wbportfolio.order_routing import ExecutionStatus
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from wbportfolio.models import OrderProposal
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _should_route_as_draft() -> bool:
|
|
15
|
-
"""Determine whether orders should be routed as drafts."""
|
|
16
|
-
return getattr(settings, "DEBUG", True) or getattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _update_orders_with_confirmations(order_proposal, confirmations, rebalancing_comment):
|
|
20
|
-
"""Update all orders in the proposal based on confirmed executions."""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def execute_orders(order_proposal: "OrderProposal") -> tuple[ExecutionStatus, str]:
|
|
24
|
-
"""
|
|
25
|
-
Executes the prepared orders of an order proposal via its custodian adapter.
|
|
26
|
-
Updates execution statuses and handles routing errors gracefully.
|
|
27
|
-
"""
|
|
28
|
-
orders = order_proposal.prepare_orders_for_execution()
|
|
29
|
-
as_draft = _should_route_as_draft()
|
|
30
|
-
adapter = order_proposal.custodian_adapter
|
|
31
|
-
order_confirmations, rebalancing_comment = adapter.submit_rebalancing(orders, as_draft=as_draft)
|
|
32
|
-
leftover_orders = order_proposal.orders.all()
|
|
33
|
-
|
|
34
|
-
for confirmed in order_confirmations:
|
|
35
|
-
with suppress(ObjectDoesNotExist):
|
|
36
|
-
order = leftover_orders.get(id=confirmed.id)
|
|
37
|
-
order.execution_confirmed = True
|
|
38
|
-
order.execution_comment = order.comment
|
|
39
|
-
order.save()
|
|
40
|
-
leftover_orders = leftover_orders.exclude(id=order.id)
|
|
41
|
-
|
|
42
|
-
# Orders without confirmation
|
|
43
|
-
leftover_orders.update(execution_confirmed=False, execution_comment="No confirmation received from the custodian")
|
|
44
|
-
return ExecutionStatus.IN_DRAFT if as_draft else ExecutionStatus.PENDING, rebalancing_comment
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def get_execution_status(order_proposal: "OrderProposal") -> tuple[ExecutionStatus, str]:
|
|
48
|
-
adapter = order_proposal.custodian_adapter
|
|
49
|
-
return adapter.get_rebalance_status()
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def cancel_rebalancing(order_proposal: "OrderProposal") -> bool:
|
|
53
|
-
adapter = order_proposal.custodian_adapter
|
|
54
|
-
return adapter.cancel_current_rebalancing()
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import math
|
|
2
|
-
from datetime import date
|
|
3
|
-
from decimal import Decimal
|
|
4
|
-
|
|
5
|
-
import cvxpy as cp
|
|
6
|
-
import numpy as np
|
|
7
|
-
from django.core.exceptions import ValidationError
|
|
8
|
-
|
|
9
|
-
from wbportfolio.pms.typing import Portfolio, Trade, TradeBatch
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TradeShareOptimizer:
|
|
13
|
-
def __init__(self, batch: TradeBatch, portfolio_total_value: float):
|
|
14
|
-
self.batch = batch
|
|
15
|
-
self.portfolio_total_value = portfolio_total_value
|
|
16
|
-
|
|
17
|
-
def optimize(self, target_cash: float = 0.99):
|
|
18
|
-
try:
|
|
19
|
-
return self.optimize_trade_share(target_cash)
|
|
20
|
-
except ValueError:
|
|
21
|
-
return self.floor_trade_share()
|
|
22
|
-
|
|
23
|
-
def optimize_trade_share(self, target_cash: float = 0.01):
|
|
24
|
-
prices_fx_portfolio = np.array([trade.price_fx_portfolio for trade in self.batch.trades])
|
|
25
|
-
target_allocs = np.array([trade.target_weight for trade in self.batch.trades])
|
|
26
|
-
|
|
27
|
-
# Decision variable: number of shares (integers)
|
|
28
|
-
shares = cp.Variable(len(prices_fx_portfolio), integer=True)
|
|
29
|
-
|
|
30
|
-
# Calculate portfolio values
|
|
31
|
-
portfolio_values = cp.multiply(shares, prices_fx_portfolio)
|
|
32
|
-
|
|
33
|
-
# Target values based on allocations
|
|
34
|
-
target_values = self.portfolio_total_value * target_allocs
|
|
35
|
-
|
|
36
|
-
# Objective: minimize absolute deviation from target values
|
|
37
|
-
objective = cp.Minimize(cp.sum(cp.abs(portfolio_values - target_values)))
|
|
38
|
-
|
|
39
|
-
# Constraints
|
|
40
|
-
constraints = [
|
|
41
|
-
shares >= 0, # No short selling
|
|
42
|
-
cp.sum(portfolio_values) <= self.portfolio_total_value, # Don't exceed budget
|
|
43
|
-
cp.sum(portfolio_values) >= (1.0 - target_cash) * self.portfolio_total_value, # Use at least 99% of budget
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
# Solve
|
|
47
|
-
problem = cp.Problem(objective, constraints)
|
|
48
|
-
problem.solve(solver=cp.CBC)
|
|
49
|
-
|
|
50
|
-
if problem != "optimal":
|
|
51
|
-
raise ValueError(f"Optimization failed: {problem.status}")
|
|
52
|
-
|
|
53
|
-
shares_result = shares.value.astype(int)
|
|
54
|
-
return TradeBatch(
|
|
55
|
-
[
|
|
56
|
-
trade.normalize_target(target_shares=shares_result[index])
|
|
57
|
-
for index, trade in enumerate(self.batch.trades)
|
|
58
|
-
]
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
def floor_trade_share(self):
|
|
62
|
-
return TradeBatch(
|
|
63
|
-
[trade.normalize_target(target_shares=math.floor(trade.target_shares)) for trade in self.batch.trades]
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class TradingService:
|
|
68
|
-
"""
|
|
69
|
-
This class represents the trading service. It can be instantiated either with the target portfolio and the effective portfolio or given a direct list of trade
|
|
70
|
-
In any case, it will compute all three states
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
def __init__(
|
|
74
|
-
self,
|
|
75
|
-
trade_date: date,
|
|
76
|
-
effective_portfolio: Portfolio | None = None,
|
|
77
|
-
target_portfolio: Portfolio | None = None,
|
|
78
|
-
total_target_weight: Decimal = Decimal("1.0"),
|
|
79
|
-
):
|
|
80
|
-
self.trade_date = trade_date
|
|
81
|
-
if target_portfolio is None:
|
|
82
|
-
target_portfolio = Portfolio(positions=())
|
|
83
|
-
if effective_portfolio is None:
|
|
84
|
-
effective_portfolio = Portfolio(positions=())
|
|
85
|
-
# If effective portfolio and trades batch is provided, we ensure the trade batch contains at least one trade for every position
|
|
86
|
-
self.trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio).normalize(
|
|
87
|
-
total_target_weight
|
|
88
|
-
)
|
|
89
|
-
self._effective_portfolio = effective_portfolio
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def errors(self) -> list[str]:
|
|
93
|
-
"""
|
|
94
|
-
Returned the list of errors stored during the validation process. Can only be called after is_valid
|
|
95
|
-
"""
|
|
96
|
-
if not hasattr(self, "_errors"):
|
|
97
|
-
msg = "You must call `.is_valid()` before accessing `.errors`."
|
|
98
|
-
raise AssertionError(msg)
|
|
99
|
-
return self._errors
|
|
100
|
-
|
|
101
|
-
@property
|
|
102
|
-
def validated_trades(self) -> list[Trade]:
|
|
103
|
-
"""
|
|
104
|
-
Returned the list of validated trade stored during the validation process. Can only be called after is_valid
|
|
105
|
-
"""
|
|
106
|
-
if not hasattr(self, "_validated_trades"):
|
|
107
|
-
msg = "You must call `.is_valid()` before accessing `.validated_trades`."
|
|
108
|
-
raise AssertionError(msg)
|
|
109
|
-
return self._validated_trades
|
|
110
|
-
|
|
111
|
-
def run_validation(self, validated_trades: list[Trade]):
|
|
112
|
-
"""
|
|
113
|
-
Test the given value against all the validators on the field,
|
|
114
|
-
and either raise a `ValidationError` or simply return.
|
|
115
|
-
"""
|
|
116
|
-
if self._effective_portfolio:
|
|
117
|
-
for trade in validated_trades:
|
|
118
|
-
if (
|
|
119
|
-
trade.previous_weight
|
|
120
|
-
and trade.underlying_instrument not in self._effective_portfolio.positions_map
|
|
121
|
-
):
|
|
122
|
-
raise ValidationError("All effective position needs to be matched with a validated trade")
|
|
123
|
-
|
|
124
|
-
def build_trade_batch(
|
|
125
|
-
self,
|
|
126
|
-
effective_portfolio: Portfolio,
|
|
127
|
-
target_portfolio: Portfolio,
|
|
128
|
-
) -> TradeBatch:
|
|
129
|
-
"""
|
|
130
|
-
Given combination of effective portfolio and either a trades batch or a target portfolio, ensure all theres variables are set
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
effective_portfolio: The effective portfolio
|
|
134
|
-
target_portfolio: The optional target portfolio
|
|
135
|
-
trades_batch: The optional trades batch
|
|
136
|
-
|
|
137
|
-
Returns: The normalized trades batch
|
|
138
|
-
"""
|
|
139
|
-
|
|
140
|
-
instruments = effective_portfolio.positions_map.copy()
|
|
141
|
-
instruments.update(target_portfolio.positions_map)
|
|
142
|
-
|
|
143
|
-
trades: list[Trade] = []
|
|
144
|
-
for instrument_id, pos in instruments.items():
|
|
145
|
-
previous_weight = target_weight = 0
|
|
146
|
-
effective_shares = target_shares = 0
|
|
147
|
-
daily_return = 0
|
|
148
|
-
is_cash = False
|
|
149
|
-
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
150
|
-
previous_weight = effective_pos.weighting
|
|
151
|
-
effective_shares = effective_pos.shares
|
|
152
|
-
daily_return = effective_pos.daily_return
|
|
153
|
-
is_cash = effective_pos.is_cash
|
|
154
|
-
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
155
|
-
target_weight = target_pos.weighting
|
|
156
|
-
is_cash = target_pos.is_cash
|
|
157
|
-
if target_pos.shares is not None:
|
|
158
|
-
target_shares = target_pos.shares
|
|
159
|
-
trade = Trade(
|
|
160
|
-
underlying_instrument=instrument_id,
|
|
161
|
-
previous_weight=previous_weight,
|
|
162
|
-
target_weight=target_weight,
|
|
163
|
-
effective_shares=effective_shares,
|
|
164
|
-
target_shares=target_shares,
|
|
165
|
-
date=self.trade_date,
|
|
166
|
-
instrument_type=pos.instrument_type,
|
|
167
|
-
currency=pos.currency,
|
|
168
|
-
price=Decimal(pos.price) if pos.price is not None else Decimal("0"),
|
|
169
|
-
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
170
|
-
daily_return=Decimal(daily_return),
|
|
171
|
-
portfolio_contribution=effective_portfolio.portfolio_contribution,
|
|
172
|
-
is_cash=is_cash,
|
|
173
|
-
)
|
|
174
|
-
trades.append(trade)
|
|
175
|
-
return TradeBatch(trades)
|
|
176
|
-
|
|
177
|
-
def is_valid(self, ignore_error: bool = False) -> bool:
|
|
178
|
-
"""
|
|
179
|
-
Validate the trade batch against a set of default rules. Populate the validated_trades and errors property.
|
|
180
|
-
Ignore error by default
|
|
181
|
-
Args:
|
|
182
|
-
ignore_error: If true, will raise the error. False by default
|
|
183
|
-
|
|
184
|
-
Returns: True if the trades batch is valid
|
|
185
|
-
"""
|
|
186
|
-
if not hasattr(self, "_validated_trades"):
|
|
187
|
-
self._validated_trades = []
|
|
188
|
-
self._errors = []
|
|
189
|
-
# Run validation for every trade. If a trade is not valid, we simply exclude it from the validated trades list
|
|
190
|
-
for _, trade in self.trades_batch.trades_map.items():
|
|
191
|
-
try:
|
|
192
|
-
trade.validate()
|
|
193
|
-
self._validated_trades.append(trade)
|
|
194
|
-
except ValidationError as exc:
|
|
195
|
-
self._errors.append(exc.message)
|
|
196
|
-
try:
|
|
197
|
-
# Check the overall validity of the trade batch. If this fail, we consider all trade invalids
|
|
198
|
-
self.run_validation(self._validated_trades)
|
|
199
|
-
except ValidationError as exc:
|
|
200
|
-
self._validated_trades = []
|
|
201
|
-
self._errors.append(exc.message)
|
|
202
|
-
|
|
203
|
-
if self._errors and not ignore_error:
|
|
204
|
-
raise ValidationError(self.errors)
|
|
205
|
-
|
|
206
|
-
return not bool(self._errors)
|
|
207
|
-
|
|
208
|
-
def get_optimized_trade_batch(self, portfolio_total_value: float, target_cash: float):
|
|
209
|
-
return TradeShareOptimizer(
|
|
210
|
-
self.trades_batch, portfolio_total_value
|
|
211
|
-
).floor_trade_share() # TODO switch to the other optimization when ready
|
|
File without changes
|
|
File without changes
|