wbportfolio 1.52.5__py2.py3-none-any.whl → 1.53.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.
- wbportfolio/factories/portfolios.py +1 -0
- wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
- wbportfolio/models/asset.py +3 -2
- wbportfolio/models/portfolio.py +17 -2
- wbportfolio/models/transactions/trade_proposals.py +80 -71
- wbportfolio/models/transactions/trades.py +66 -32
- wbportfolio/pms/trading/handler.py +104 -55
- wbportfolio/pms/typing.py +63 -20
- wbportfolio/rebalancing/models/model_portfolio.py +11 -14
- wbportfolio/serializers/transactions/trade_proposals.py +18 -1
- wbportfolio/serializers/transactions/trades.py +33 -5
- wbportfolio/tests/models/test_portfolios.py +10 -9
- wbportfolio/tests/models/transactions/test_trade_proposals.py +230 -13
- wbportfolio/tests/rebalancing/test_models.py +10 -16
- wbportfolio/viewsets/configs/buttons/trade_proposals.py +9 -1
- wbportfolio/viewsets/configs/display/trade_proposals.py +5 -4
- wbportfolio/viewsets/configs/display/trades.py +36 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +3 -1
- wbportfolio/viewsets/transactions/trades.py +53 -45
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/RECORD +23 -22
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,69 @@
|
|
|
1
|
+
import math
|
|
1
2
|
from datetime import date
|
|
2
3
|
from decimal import Decimal
|
|
3
4
|
|
|
5
|
+
import cvxpy as cp
|
|
6
|
+
import numpy as np
|
|
4
7
|
from django.core.exceptions import ValidationError
|
|
5
8
|
|
|
6
9
|
from wbportfolio.pms.typing import Portfolio, Trade, TradeBatch
|
|
7
10
|
|
|
8
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
|
+
|
|
9
67
|
class TradingService:
|
|
10
68
|
"""
|
|
11
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
|
|
@@ -17,25 +75,20 @@ class TradingService:
|
|
|
17
75
|
trade_date: date,
|
|
18
76
|
effective_portfolio: Portfolio | None = None,
|
|
19
77
|
target_portfolio: Portfolio | None = None,
|
|
20
|
-
|
|
21
|
-
total_value: Decimal = None,
|
|
78
|
+
total_target_weight: Decimal = Decimal("1.0"),
|
|
22
79
|
):
|
|
23
|
-
self.total_value = total_value
|
|
24
80
|
self.trade_date = trade_date
|
|
25
81
|
if target_portfolio is None:
|
|
26
82
|
target_portfolio = Portfolio(positions=())
|
|
27
83
|
if effective_portfolio is None:
|
|
28
84
|
effective_portfolio = Portfolio(positions=())
|
|
29
|
-
# If effective
|
|
30
|
-
trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if trades_batch and not target_portfolio:
|
|
34
|
-
target_portfolio = trades_batch.convert_to_portfolio()
|
|
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
|
+
)
|
|
35
89
|
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
self.target_portfolio = target_portfolio
|
|
90
|
+
self._effective_portfolio = effective_portfolio
|
|
91
|
+
self._target_portfolio = target_portfolio
|
|
39
92
|
|
|
40
93
|
@property
|
|
41
94
|
def errors(self) -> list[str]:
|
|
@@ -62,12 +115,11 @@ class TradingService:
|
|
|
62
115
|
Test the given value against all the validators on the field,
|
|
63
116
|
and either raise a `ValidationError` or simply return.
|
|
64
117
|
"""
|
|
65
|
-
|
|
66
|
-
if self.effective_portfolio:
|
|
118
|
+
if self._effective_portfolio:
|
|
67
119
|
for trade in validated_trades:
|
|
68
120
|
if (
|
|
69
121
|
trade.effective_weight
|
|
70
|
-
and trade.underlying_instrument not in self.
|
|
122
|
+
and trade.underlying_instrument not in self._effective_portfolio.positions_map
|
|
71
123
|
):
|
|
72
124
|
raise ValidationError("All effective position needs to be matched with a validated trade")
|
|
73
125
|
|
|
@@ -75,7 +127,6 @@ class TradingService:
|
|
|
75
127
|
self,
|
|
76
128
|
effective_portfolio: Portfolio,
|
|
77
129
|
target_portfolio: Portfolio,
|
|
78
|
-
trades_batch: TradeBatch | None = None,
|
|
79
130
|
) -> TradeBatch:
|
|
80
131
|
"""
|
|
81
132
|
Given combination of effective portfolio and either a trades batch or a target portfolio, ensure all theres variables are set
|
|
@@ -87,39 +138,40 @@ class TradingService:
|
|
|
87
138
|
|
|
88
139
|
Returns: The normalized trades batch
|
|
89
140
|
"""
|
|
90
|
-
instruments =
|
|
91
|
-
instruments.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
instruments = effective_portfolio.positions_map.copy()
|
|
142
|
+
instruments.update(target_portfolio.positions_map)
|
|
143
|
+
|
|
144
|
+
trades: list[Trade] = []
|
|
145
|
+
for instrument_id, pos in instruments.items():
|
|
146
|
+
if not pos.is_cash:
|
|
147
|
+
effective_weight = target_weight = 0
|
|
148
|
+
effective_shares = target_shares = 0
|
|
149
|
+
drift_factor = 1.0
|
|
150
|
+
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
151
|
+
effective_weight = effective_pos.weighting
|
|
152
|
+
effective_shares = effective_pos.shares
|
|
153
|
+
drift_factor = effective_pos.drift_factor
|
|
154
|
+
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
155
|
+
target_weight = target_pos.weighting
|
|
156
|
+
if target_pos.shares is not None:
|
|
157
|
+
target_shares = target_pos.shares
|
|
158
|
+
|
|
159
|
+
trades.append(
|
|
160
|
+
Trade(
|
|
161
|
+
underlying_instrument=instrument_id,
|
|
162
|
+
effective_weight=effective_weight,
|
|
163
|
+
target_weight=target_weight,
|
|
164
|
+
effective_shares=effective_shares,
|
|
165
|
+
target_shares=target_shares,
|
|
166
|
+
date=self.trade_date,
|
|
167
|
+
instrument_type=pos.instrument_type,
|
|
168
|
+
currency=pos.currency,
|
|
169
|
+
price=Decimal(pos.price),
|
|
170
|
+
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
171
|
+
drift_factor=Decimal(drift_factor),
|
|
172
|
+
)
|
|
120
173
|
)
|
|
121
|
-
|
|
122
|
-
return TradeBatch(tuple(_trades))
|
|
174
|
+
return TradeBatch(tuple(trades))
|
|
123
175
|
|
|
124
176
|
def is_valid(self, ignore_error: bool = False) -> bool:
|
|
125
177
|
"""
|
|
@@ -152,10 +204,7 @@ class TradingService:
|
|
|
152
204
|
|
|
153
205
|
return not bool(self._errors)
|
|
154
206
|
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
self.trades_batch = TradeBatch(
|
|
160
|
-
[trade.normalize_target(self.trades_batch.total_target_weight) for trade in self.trades_batch.trades]
|
|
161
|
-
)
|
|
207
|
+
def get_optimized_trade_batch(self, portfolio_total_value: float, target_cash: float):
|
|
208
|
+
return TradeShareOptimizer(
|
|
209
|
+
self.trades_batch, portfolio_total_value
|
|
210
|
+
).floor_trade_share() # TODO switch to the other optimization when ready
|
wbportfolio/pms/typing.py
CHANGED
|
@@ -19,6 +19,7 @@ class Position:
|
|
|
19
19
|
weighting: Decimal
|
|
20
20
|
date: date_lib
|
|
21
21
|
|
|
22
|
+
drift_factor: float = 1.0
|
|
22
23
|
currency: int | None = None
|
|
23
24
|
instrument_type: int | None = None
|
|
24
25
|
asset_valuation_date: date_lib | None = None
|
|
@@ -48,7 +49,7 @@ class Position:
|
|
|
48
49
|
@dataclass(frozen=True)
|
|
49
50
|
class Portfolio:
|
|
50
51
|
positions: tuple[Position] | tuple
|
|
51
|
-
positions_map: dict[Position] = field(init=False, repr=False)
|
|
52
|
+
positions_map: dict[int, Position] = field(init=False, repr=False)
|
|
52
53
|
|
|
53
54
|
def __post_init__(self):
|
|
54
55
|
positions_map = {}
|
|
@@ -80,23 +81,26 @@ class Portfolio:
|
|
|
80
81
|
@dataclass(frozen=True)
|
|
81
82
|
class Trade:
|
|
82
83
|
underlying_instrument: int
|
|
83
|
-
instrument_type:
|
|
84
|
+
instrument_type: int
|
|
84
85
|
currency: int
|
|
85
86
|
date: date_lib
|
|
86
|
-
|
|
87
|
+
price: Decimal
|
|
87
88
|
effective_weight: Decimal
|
|
88
89
|
target_weight: Decimal
|
|
90
|
+
currency_fx_rate: Decimal = Decimal("1")
|
|
91
|
+
effective_shares: Decimal = Decimal("0")
|
|
92
|
+
target_shares: Decimal = Decimal("0")
|
|
93
|
+
drift_factor: Decimal = Decimal("1")
|
|
89
94
|
id: int | None = None
|
|
90
|
-
|
|
95
|
+
is_cash: bool = False
|
|
91
96
|
|
|
92
97
|
def __add__(self, other):
|
|
93
98
|
return Trade(
|
|
94
99
|
underlying_instrument=self.underlying_instrument,
|
|
95
|
-
effective_weight=self.effective_weight
|
|
100
|
+
effective_weight=self.effective_weight,
|
|
96
101
|
target_weight=self.target_weight + other.target_weight,
|
|
97
|
-
effective_shares=self.effective_shares
|
|
98
|
-
|
|
99
|
-
else None,
|
|
102
|
+
effective_shares=self.effective_shares,
|
|
103
|
+
target_shares=self.target_shares + other.target_shares,
|
|
100
104
|
**{
|
|
101
105
|
f.name: getattr(self, f.name)
|
|
102
106
|
for f in fields(Trade)
|
|
@@ -105,15 +109,29 @@ class Trade:
|
|
|
105
109
|
"effective_weight",
|
|
106
110
|
"target_weight",
|
|
107
111
|
"effective_shares",
|
|
112
|
+
"target_shares",
|
|
108
113
|
"underlying_instrument",
|
|
109
114
|
]
|
|
110
115
|
},
|
|
111
116
|
)
|
|
112
117
|
|
|
118
|
+
def copy(self, **kwargs):
|
|
119
|
+
attrs = {f.name: getattr(self, f.name) for f in fields(Trade)}
|
|
120
|
+
attrs.update(kwargs)
|
|
121
|
+
return Trade(**attrs)
|
|
122
|
+
|
|
113
123
|
@property
|
|
114
124
|
def delta_weight(self) -> Decimal:
|
|
115
125
|
return self.target_weight - self.effective_weight
|
|
116
126
|
|
|
127
|
+
@property
|
|
128
|
+
def delta_shares(self) -> Decimal:
|
|
129
|
+
return self.target_shares - self.effective_shares
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def price_fx_portfolio(self) -> Decimal:
|
|
133
|
+
return self.price * self.currency_fx_rate
|
|
134
|
+
|
|
117
135
|
def validate(self):
|
|
118
136
|
return True
|
|
119
137
|
# if self.effective_weight < 0 or self.effective_weight > 1.0:
|
|
@@ -121,17 +139,22 @@ class Trade:
|
|
|
121
139
|
# if self.target_weight < 0 or self.target_weight > 1.0:
|
|
122
140
|
# raise ValidationError("Target Weight needs to be in range [0, 1]")
|
|
123
141
|
|
|
124
|
-
def normalize_target(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
def normalize_target(
|
|
143
|
+
self, factor: Decimal | None = None, target_shares: Decimal | int | None = None, target_weight: Decimal = None
|
|
144
|
+
):
|
|
145
|
+
if factor is None:
|
|
146
|
+
if target_shares is not None:
|
|
147
|
+
factor = target_shares / self.target_shares if self.target_shares else Decimal("1")
|
|
148
|
+
elif target_weight is not None:
|
|
149
|
+
factor = target_weight / self.target_weight if self.target_weight else Decimal("1")
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError("Target weight and shares cannot be both None")
|
|
152
|
+
return self.copy(target_weight=self.target_weight * factor, target_shares=self.target_shares * factor)
|
|
130
153
|
|
|
131
154
|
|
|
132
155
|
@dataclass(frozen=True)
|
|
133
156
|
class TradeBatch:
|
|
134
|
-
trades:
|
|
157
|
+
trades: list[Trade]
|
|
135
158
|
trades_map: dict[Trade] = field(init=False, repr=False)
|
|
136
159
|
|
|
137
160
|
def __post_init__(self):
|
|
@@ -145,15 +168,15 @@ class TradeBatch:
|
|
|
145
168
|
|
|
146
169
|
@property
|
|
147
170
|
def total_target_weight(self) -> Decimal:
|
|
148
|
-
return round(sum([trade.target_weight for trade in self.trades]), 6)
|
|
171
|
+
return round(sum([trade.target_weight for trade in self.trades], Decimal("0")), 6)
|
|
149
172
|
|
|
150
173
|
@property
|
|
151
174
|
def total_effective_weight(self) -> Decimal:
|
|
152
|
-
return round(sum([trade.effective_weight for trade in self.trades]), 6)
|
|
175
|
+
return round(sum([trade.effective_weight for trade in self.trades], Decimal("0")), 6)
|
|
153
176
|
|
|
154
177
|
@property
|
|
155
|
-
def
|
|
156
|
-
return sum([abs(trade.delta_weight) for trade in self.trades])
|
|
178
|
+
def total_abs_delta_weight(self) -> Decimal:
|
|
179
|
+
return sum([abs(trade.delta_weight) for trade in self.trades], Decimal("0"))
|
|
157
180
|
|
|
158
181
|
def __add__(self, other):
|
|
159
182
|
return TradeBatch(tuple(self.trades + other.trades))
|
|
@@ -165,7 +188,7 @@ class TradeBatch:
|
|
|
165
188
|
if round(float(self.total_target_weight), 4) != 1: # we do that to remove decimal over precision
|
|
166
189
|
raise ValidationError(f"Total Weight cannot be different than 1 ({float(self.total_target_weight)})")
|
|
167
190
|
|
|
168
|
-
def convert_to_portfolio(self):
|
|
191
|
+
def convert_to_portfolio(self, *extra_positions):
|
|
169
192
|
positions = []
|
|
170
193
|
for instrument, trade in self.trades_map.items():
|
|
171
194
|
positions.append(
|
|
@@ -173,8 +196,28 @@ class TradeBatch:
|
|
|
173
196
|
underlying_instrument=trade.underlying_instrument,
|
|
174
197
|
instrument_type=trade.instrument_type,
|
|
175
198
|
weighting=trade.target_weight,
|
|
199
|
+
shares=trade.target_shares,
|
|
176
200
|
currency=trade.currency,
|
|
177
201
|
date=trade.date,
|
|
202
|
+
is_cash=trade.is_cash,
|
|
203
|
+
price=trade.price,
|
|
204
|
+
currency_fx_rate=trade.currency_fx_rate,
|
|
178
205
|
)
|
|
179
206
|
)
|
|
207
|
+
for position in extra_positions:
|
|
208
|
+
if position.weighting:
|
|
209
|
+
positions.append(position)
|
|
180
210
|
return Portfolio(tuple(positions))
|
|
211
|
+
|
|
212
|
+
def normalize(self, total_target_weight: Decimal = Decimal("1.0")):
|
|
213
|
+
"""
|
|
214
|
+
Normalize the instantiate trades batch so that the target weight is 100%
|
|
215
|
+
"""
|
|
216
|
+
normalization_factor = (
|
|
217
|
+
total_target_weight / self.total_target_weight if self.total_target_weight else Decimal("0.0")
|
|
218
|
+
)
|
|
219
|
+
normalized_trades = []
|
|
220
|
+
for trade in self.trades:
|
|
221
|
+
normalized_trades.append(trade.normalize_target(normalization_factor))
|
|
222
|
+
tb = TradeBatch(normalized_trades)
|
|
223
|
+
return tb
|
|
@@ -16,25 +16,22 @@ class ModelPortfolioRebalancing(AbstractRebalancingModel):
|
|
|
16
16
|
|
|
17
17
|
@property
|
|
18
18
|
def model_portfolio(self):
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
return self.model_portfolio_rel.dependency_portfolio if self.model_portfolio_rel else None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def assets(self):
|
|
23
|
+
return self.model_portfolio.get_positions(self.last_effective_date) if self.model_portfolio else []
|
|
21
24
|
|
|
22
25
|
def is_valid(self) -> bool:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
date=self.trade_date, instrument__in=assets.values("underlying_quote")
|
|
29
|
-
).exists()
|
|
30
|
-
)
|
|
31
|
-
return False
|
|
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
|
+
)
|
|
32
31
|
|
|
33
32
|
def get_target_portfolio(self) -> Portfolio:
|
|
34
33
|
positions = []
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for asset in assets:
|
|
34
|
+
for asset in self.assets:
|
|
38
35
|
asset.date = self.trade_date
|
|
39
36
|
asset.asset_valuation_date = self.trade_date
|
|
40
37
|
positions.append(asset._build_dto())
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
1
3
|
from django.contrib.messages import warning
|
|
2
4
|
from django.core.exceptions import ValidationError
|
|
3
5
|
from rest_framework.reverse import reverse
|
|
@@ -16,6 +18,17 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
16
18
|
queryset=Portfolio.objects.all(), write_only=True, required=False, default=DefaultFromView("portfolio")
|
|
17
19
|
)
|
|
18
20
|
_target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
|
|
21
|
+
total_cash_weight = wb_serializers.DecimalField(
|
|
22
|
+
default=0,
|
|
23
|
+
decimal_places=4,
|
|
24
|
+
max_digits=5,
|
|
25
|
+
write_only=True,
|
|
26
|
+
required=False,
|
|
27
|
+
precision=4,
|
|
28
|
+
percent=True,
|
|
29
|
+
label="Target Cash",
|
|
30
|
+
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%.",
|
|
31
|
+
)
|
|
19
32
|
|
|
20
33
|
trade_date = wb_serializers.DateField(
|
|
21
34
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
@@ -23,6 +36,7 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
23
36
|
|
|
24
37
|
def create(self, validated_data):
|
|
25
38
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
39
|
+
total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
|
|
26
40
|
rebalancing_model = validated_data.get("rebalancing_model", None)
|
|
27
41
|
if request := self.context.get("request"):
|
|
28
42
|
validated_data["creator"] = request.user.profile
|
|
@@ -32,7 +46,9 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
32
46
|
if target_portfolio and not rebalancing_model and (last_effective_date := obj.last_effective_date):
|
|
33
47
|
target_portfolio_dto = target_portfolio._build_dto(last_effective_date)
|
|
34
48
|
try:
|
|
35
|
-
obj.reset_trades(
|
|
49
|
+
obj.reset_trades(
|
|
50
|
+
target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
|
|
51
|
+
)
|
|
36
52
|
except ValidationError as e:
|
|
37
53
|
if request := self.context.get("request"):
|
|
38
54
|
warning(request, str(e), extra_tags="auto_close=0")
|
|
@@ -60,6 +76,7 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
60
76
|
fields = (
|
|
61
77
|
"id",
|
|
62
78
|
"trade_date",
|
|
79
|
+
"total_cash_weight",
|
|
63
80
|
"comment",
|
|
64
81
|
"status",
|
|
65
82
|
"portfolio",
|
|
@@ -315,11 +315,22 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
315
315
|
)
|
|
316
316
|
|
|
317
317
|
status = wb_serializers.ChoiceField(default=Trade.Status.DRAFT, choices=Trade.Status.choices)
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
weighting = wb_serializers.DecimalField(max_digits=7, decimal_places=6)
|
|
319
|
+
target_weight = wb_serializers.DecimalField(max_digits=7, decimal_places=6, required=False, default=0)
|
|
320
|
+
effective_weight = wb_serializers.DecimalField(read_only=True, max_digits=7, decimal_places=6, default=0)
|
|
320
321
|
effective_shares = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
321
322
|
target_shares = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
322
323
|
|
|
324
|
+
total_value_fx_portfolio = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=2, default=0)
|
|
325
|
+
effective_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
326
|
+
read_only=True, max_digits=16, decimal_places=2, default=0
|
|
327
|
+
)
|
|
328
|
+
target_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
329
|
+
read_only=True, max_digits=16, decimal_places=2, default=0
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
portfolio_currency = wb_serializers.CharField(read_only=True)
|
|
333
|
+
|
|
323
334
|
def validate(self, data):
|
|
324
335
|
data.pop("company", None)
|
|
325
336
|
data.pop("security", None)
|
|
@@ -331,6 +342,8 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
331
342
|
)
|
|
332
343
|
effective_weight = self.instance._effective_weight if self.instance else Decimal(0.0)
|
|
333
344
|
weighting = data.get("weighting", self.instance.weighting if self.instance else Decimal(0.0))
|
|
345
|
+
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
346
|
+
weighting = target_weight - effective_weight
|
|
334
347
|
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
335
348
|
weighting = target_weight - effective_weight
|
|
336
349
|
if weighting >= 0:
|
|
@@ -343,14 +356,25 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
343
356
|
class Meta:
|
|
344
357
|
model = Trade
|
|
345
358
|
percent_fields = ["effective_weight", "target_weight", "weighting"]
|
|
359
|
+
decorators = {
|
|
360
|
+
"total_value_fx_portfolio": wb_serializers.decorator(
|
|
361
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
362
|
+
),
|
|
363
|
+
"effective_total_value_fx_portfolio": wb_serializers.decorator(
|
|
364
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
365
|
+
),
|
|
366
|
+
"target_total_value_fx_portfolio": wb_serializers.decorator(
|
|
367
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
368
|
+
),
|
|
369
|
+
}
|
|
346
370
|
read_only_fields = (
|
|
347
371
|
"transaction_subtype",
|
|
348
372
|
"shares",
|
|
349
|
-
# "underlying_instrument",
|
|
350
|
-
# "_underlying_instrument",
|
|
351
|
-
"shares",
|
|
352
373
|
"effective_shares",
|
|
353
374
|
"target_shares",
|
|
375
|
+
"total_value_fx_portfolio",
|
|
376
|
+
"effective_total_value_fx_portfolio",
|
|
377
|
+
"target_total_value_fx_portfolio",
|
|
354
378
|
)
|
|
355
379
|
fields = (
|
|
356
380
|
"id",
|
|
@@ -375,6 +399,10 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
375
399
|
"order",
|
|
376
400
|
"effective_shares",
|
|
377
401
|
"target_shares",
|
|
402
|
+
"total_value_fx_portfolio",
|
|
403
|
+
"effective_total_value_fx_portfolio",
|
|
404
|
+
"target_total_value_fx_portfolio",
|
|
405
|
+
"portfolio_currency",
|
|
378
406
|
)
|
|
379
407
|
|
|
380
408
|
|
|
@@ -1033,18 +1033,19 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1033
1033
|
|
|
1034
1034
|
i1 = instrument_factory.create(currency=portfolio.currency)
|
|
1035
1035
|
i2 = instrument_factory.create(currency=portfolio.currency)
|
|
1036
|
+
|
|
1037
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i1, date=(weekday - BDay(1)).date())
|
|
1038
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i1, date=weekday)
|
|
1039
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i1, date=middle_date)
|
|
1040
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i1, date=rebalancing_date)
|
|
1041
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i2, date=(weekday - BDay(1)).date())
|
|
1042
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i2, date=weekday)
|
|
1043
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i2, date=middle_date)
|
|
1044
|
+
instrument_price_factory.create(net_value=Decimal("100"), instrument=i2, date=rebalancing_date)
|
|
1045
|
+
|
|
1036
1046
|
asset_position_factory.create(date=weekday, portfolio=portfolio, underlying_instrument=i1, weighting=0.7)
|
|
1037
1047
|
asset_position_factory.create(date=weekday, portfolio=portfolio, underlying_instrument=i2, weighting=0.3)
|
|
1038
1048
|
|
|
1039
|
-
instrument_price_factory.create(instrument=i1, date=(weekday - BDay(1)).date())
|
|
1040
|
-
instrument_price_factory.create(instrument=i1, date=weekday)
|
|
1041
|
-
instrument_price_factory.create(instrument=i1, date=middle_date)
|
|
1042
|
-
instrument_price_factory.create(instrument=i1, date=rebalancing_date)
|
|
1043
|
-
instrument_price_factory.create(instrument=i2, date=(weekday - BDay(1)).date())
|
|
1044
|
-
instrument_price_factory.create(instrument=i2, date=weekday)
|
|
1045
|
-
instrument_price_factory.create(instrument=i2, date=middle_date)
|
|
1046
|
-
instrument_price_factory.create(instrument=i2, date=rebalancing_date)
|
|
1047
|
-
|
|
1048
1049
|
rebalancer_factory.create(portfolio=portfolio, frequency="RRULE:FREQ=DAILY;", activation_date=rebalancing_date)
|
|
1049
1050
|
positions, rebalancing_trade_proposal = portfolio.drift_weights(weekday, (rebalancing_date + BDay(1)).date())
|
|
1050
1051
|
assert rebalancing_trade_proposal.trade_date == rebalancing_date
|