wbportfolio 1.52.6__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 +2 -1
- wbportfolio/models/portfolio.py +16 -1
- wbportfolio/models/transactions/trade_proposals.py +23 -5
- wbportfolio/models/transactions/trades.py +44 -19
- wbportfolio/pms/trading/handler.py +74 -6
- wbportfolio/pms/typing.py +38 -13
- wbportfolio/serializers/transactions/trades.py +3 -2
- wbportfolio/tests/models/test_portfolios.py +10 -9
- wbportfolio/tests/models/transactions/test_trade_proposals.py +223 -8
- wbportfolio/viewsets/transactions/trades.py +2 -0
- {wbportfolio-1.52.6.dist-info → wbportfolio-1.53.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.52.6.dist-info → wbportfolio-1.53.0.dist-info}/RECORD +16 -15
- {wbportfolio-1.52.6.dist-info → wbportfolio-1.53.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.52.6.dist-info → wbportfolio-1.53.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-07-02 13:10
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('wbportfolio', '0077_remove_transaction_currency_and_more'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='trade',
|
|
16
|
+
name='drift_factor',
|
|
17
|
+
field=models.DecimalField(decimal_places=6, default=Decimal('1'), help_text='Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return', max_digits=16, verbose_name='Drift Factor'),
|
|
18
|
+
),
|
|
19
|
+
migrations.AlterField(
|
|
20
|
+
model_name='trade',
|
|
21
|
+
name='weighting',
|
|
22
|
+
field=models.DecimalField(decimal_places=8, default=Decimal('0'),
|
|
23
|
+
help_text='The weight to be multiplied against the target', max_digits=9,
|
|
24
|
+
verbose_name='Weight'),
|
|
25
|
+
),
|
|
26
|
+
]
|
wbportfolio/models/asset.py
CHANGED
|
@@ -693,7 +693,7 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
693
693
|
def get_portfolio_total_asset_value(self) -> Decimal:
|
|
694
694
|
return self.portfolio.get_total_asset_value(self.date)
|
|
695
695
|
|
|
696
|
-
def _build_dto(self, new_weight: Decimal = None) -> PositionDTO:
|
|
696
|
+
def _build_dto(self, new_weight: Decimal = None, **kwargs) -> PositionDTO:
|
|
697
697
|
"""
|
|
698
698
|
Data Transfer Object
|
|
699
699
|
Returns:
|
|
@@ -726,6 +726,7 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
726
726
|
price=self._price,
|
|
727
727
|
currency_fx_rate=self._currency_fx_rate,
|
|
728
728
|
portfolio_created=self.portfolio_created.id if self.portfolio_created else None,
|
|
729
|
+
**kwargs,
|
|
729
730
|
)
|
|
730
731
|
|
|
731
732
|
@cached_property
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -355,8 +355,23 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
355
355
|
|
|
356
356
|
def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
|
|
357
357
|
"returns the dto representation of this portfolio at the specified date"
|
|
358
|
+
assets = self.assets.filter(date=val_date, **extra_kwargs)
|
|
359
|
+
try:
|
|
360
|
+
drifted_weights = self.get_analytic_portfolio(val_date).get_next_weights()
|
|
361
|
+
except InvalidAnalyticPortfolio:
|
|
362
|
+
drifted_weights = {}
|
|
358
363
|
return PortfolioDTO(
|
|
359
|
-
tuple(
|
|
364
|
+
tuple(
|
|
365
|
+
[
|
|
366
|
+
pos._build_dto(
|
|
367
|
+
drift_factor=drifted_weights.get(pos.underlying_quote.id, float(pos.weighting))
|
|
368
|
+
/ float(pos.weighting)
|
|
369
|
+
if pos.weighting
|
|
370
|
+
else Decimal(1.0)
|
|
371
|
+
)
|
|
372
|
+
for pos in assets
|
|
373
|
+
]
|
|
374
|
+
),
|
|
360
375
|
)
|
|
361
376
|
|
|
362
377
|
def get_weights(self, val_date: date) -> dict[int, float]:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import math
|
|
2
3
|
from contextlib import suppress
|
|
3
4
|
from datetime import date, timedelta
|
|
4
5
|
from decimal import Decimal
|
|
@@ -112,7 +113,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
112
113
|
|
|
113
114
|
return TradingService(
|
|
114
115
|
self.trade_date,
|
|
115
|
-
effective_portfolio=self.
|
|
116
|
+
effective_portfolio=self._get_default_effective_portfolio(),
|
|
116
117
|
target_portfolio=target_portfolio,
|
|
117
118
|
total_target_weight=target_portfolio.total_weight,
|
|
118
119
|
)
|
|
@@ -206,7 +207,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
206
207
|
"""
|
|
207
208
|
service = TradingService(
|
|
208
209
|
self.trade_date,
|
|
209
|
-
effective_portfolio=self.
|
|
210
|
+
effective_portfolio=self._get_default_effective_portfolio(),
|
|
210
211
|
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
211
212
|
total_target_weight=total_target_weight,
|
|
212
213
|
)
|
|
@@ -239,6 +240,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
239
240
|
if self.trades.exists():
|
|
240
241
|
return self._build_dto().convert_to_portfolio()
|
|
241
242
|
# Return the current portfolio by default
|
|
243
|
+
return self._get_default_effective_portfolio()
|
|
244
|
+
|
|
245
|
+
def _get_default_effective_portfolio(self):
|
|
242
246
|
return self.portfolio._build_dto(self.last_effective_date)
|
|
243
247
|
|
|
244
248
|
def reset_trades(
|
|
@@ -253,13 +257,12 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
253
257
|
# delete all existing trades
|
|
254
258
|
last_effective_date = self.last_effective_date
|
|
255
259
|
# Get effective and target portfolio
|
|
256
|
-
effective_portfolio = self.portfolio._build_dto(last_effective_date)
|
|
257
260
|
if not target_portfolio:
|
|
258
261
|
target_portfolio = self._get_default_target_portfolio()
|
|
259
262
|
if target_portfolio:
|
|
260
263
|
service = TradingService(
|
|
261
264
|
self.trade_date,
|
|
262
|
-
effective_portfolio=
|
|
265
|
+
effective_portfolio=self._get_default_effective_portfolio(),
|
|
263
266
|
target_portfolio=target_portfolio,
|
|
264
267
|
total_target_weight=total_target_weight,
|
|
265
268
|
)
|
|
@@ -275,11 +278,13 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
275
278
|
)
|
|
276
279
|
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
277
280
|
weighting = round(trade_dto.delta_weight, 6)
|
|
281
|
+
drift_factor = trade_dto.drift_factor
|
|
278
282
|
try:
|
|
279
283
|
trade = self.trades.get(underlying_instrument=instrument)
|
|
280
284
|
trade.weighting = weighting
|
|
281
285
|
trade.currency_fx_rate = currency_fx_rate
|
|
282
286
|
trade.status = Trade.Status.DRAFT
|
|
287
|
+
trade.drift_factor = drift_factor
|
|
283
288
|
except Trade.DoesNotExist:
|
|
284
289
|
trade = Trade(
|
|
285
290
|
underlying_instrument=instrument,
|
|
@@ -289,6 +294,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
289
294
|
trade_proposal=self,
|
|
290
295
|
portfolio=self.portfolio,
|
|
291
296
|
weighting=weighting,
|
|
297
|
+
drift_factor=drift_factor,
|
|
292
298
|
status=Trade.Status.DRAFT,
|
|
293
299
|
currency_fx_rate=currency_fx_rate,
|
|
294
300
|
)
|
|
@@ -374,6 +380,16 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
374
380
|
if price_fx_portfolio:
|
|
375
381
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
376
382
|
|
|
383
|
+
def get_round_lot_size(self, shares: Decimal, underlying_quote: Instrument) -> Decimal:
|
|
384
|
+
if (round_lot_size := underlying_quote.round_lot_size) != 1 and (
|
|
385
|
+
not underlying_quote.exchange or underlying_quote.exchange.apply_round_lot_size
|
|
386
|
+
):
|
|
387
|
+
if shares > 0:
|
|
388
|
+
shares = math.ceil(shares / round_lot_size) * round_lot_size
|
|
389
|
+
elif abs(shares) > round_lot_size:
|
|
390
|
+
shares = math.floor(shares / round_lot_size) * round_lot_size
|
|
391
|
+
return shares
|
|
392
|
+
|
|
377
393
|
def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
|
|
378
394
|
"""
|
|
379
395
|
Estimates the target cash weight and shares for a trade proposal.
|
|
@@ -464,7 +480,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
464
480
|
trades = []
|
|
465
481
|
trades_validation_warnings = []
|
|
466
482
|
for trade in self.trades.all():
|
|
467
|
-
trade_warnings = trade.submit(
|
|
483
|
+
trade_warnings = trade.submit(
|
|
484
|
+
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
485
|
+
)
|
|
468
486
|
if trade_warnings:
|
|
469
487
|
trades_validation_warnings.extend(trade_warnings)
|
|
470
488
|
trades.append(trade)
|
|
@@ -53,7 +53,7 @@ class TradeQueryset(OrderedModelQuerySet):
|
|
|
53
53
|
.order_by("-date")
|
|
54
54
|
.values("date")[:1]
|
|
55
55
|
),
|
|
56
|
-
|
|
56
|
+
_previous_weight=Coalesce(
|
|
57
57
|
Subquery(
|
|
58
58
|
AssetPosition.unannotated_objects.filter(
|
|
59
59
|
underlying_quote=OuterRef("underlying_instrument"),
|
|
@@ -66,6 +66,7 @@ class TradeQueryset(OrderedModelQuerySet):
|
|
|
66
66
|
),
|
|
67
67
|
Decimal(0),
|
|
68
68
|
),
|
|
69
|
+
effective_weight=F("_previous_weight") * F("drift_factor"),
|
|
69
70
|
target_weight=F("effective_weight") + F("weighting"),
|
|
70
71
|
effective_shares=Coalesce(
|
|
71
72
|
Subquery(
|
|
@@ -163,8 +164,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
163
164
|
)
|
|
164
165
|
|
|
165
166
|
weighting = models.DecimalField(
|
|
166
|
-
max_digits=
|
|
167
|
-
decimal_places=
|
|
167
|
+
max_digits=9,
|
|
168
|
+
decimal_places=8,
|
|
168
169
|
default=Decimal(0),
|
|
169
170
|
help_text="The weight to be multiplied against the target",
|
|
170
171
|
verbose_name="Weight",
|
|
@@ -223,7 +224,13 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
223
224
|
on_delete=models.CASCADE,
|
|
224
225
|
help_text="The Trade Proposal this trade is coming from",
|
|
225
226
|
)
|
|
226
|
-
|
|
227
|
+
drift_factor = models.DecimalField(
|
|
228
|
+
max_digits=16,
|
|
229
|
+
decimal_places=6,
|
|
230
|
+
default=Decimal(1.0),
|
|
231
|
+
verbose_name="Drift Factor",
|
|
232
|
+
help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
|
|
233
|
+
)
|
|
227
234
|
external_id = models.CharField(
|
|
228
235
|
max_length=255,
|
|
229
236
|
null=True,
|
|
@@ -268,13 +275,27 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
268
275
|
},
|
|
269
276
|
on_error="FAILED",
|
|
270
277
|
)
|
|
271
|
-
def submit(self, by=None, description=None, **kwargs):
|
|
272
|
-
|
|
278
|
+
def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
|
|
279
|
+
warnings = []
|
|
280
|
+
# if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
|
|
281
|
+
if self.trade_proposal and not self.portfolio.only_weighting:
|
|
282
|
+
shares = self.trade_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
|
|
283
|
+
if shares != self.shares:
|
|
284
|
+
warnings.append(
|
|
285
|
+
f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
|
|
286
|
+
)
|
|
287
|
+
shares = round(shares) # ensure fractional shares are converted into integer
|
|
288
|
+
# we need to recompute the delta weight has we changed the number of shares
|
|
289
|
+
if shares != self.shares:
|
|
290
|
+
self.shares = shares
|
|
291
|
+
if portfolio_total_asset_value:
|
|
292
|
+
self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
|
|
293
|
+
|
|
273
294
|
if not self.price:
|
|
274
|
-
|
|
295
|
+
warnings.append(
|
|
275
296
|
f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
276
297
|
)
|
|
277
|
-
return
|
|
298
|
+
return warnings
|
|
278
299
|
|
|
279
300
|
def can_submit(self):
|
|
280
301
|
pass
|
|
@@ -457,16 +478,14 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
457
478
|
@property
|
|
458
479
|
@admin.display(description="Effective Weight")
|
|
459
480
|
def _effective_weight(self) -> Decimal:
|
|
460
|
-
|
|
461
|
-
self
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
or Decimal(0),
|
|
469
|
-
)
|
|
481
|
+
if hasattr(self, "effective_weight"):
|
|
482
|
+
return self.effective_weight
|
|
483
|
+
previous_weight = AssetPosition.unannotated_objects.filter(
|
|
484
|
+
underlying_quote=self.underlying_instrument,
|
|
485
|
+
date=self._last_effective_date,
|
|
486
|
+
portfolio=self.portfolio,
|
|
487
|
+
).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
|
|
488
|
+
return previous_weight * self.drift_factor
|
|
470
489
|
|
|
471
490
|
@property
|
|
472
491
|
@admin.display(description="Effective Shares")
|
|
@@ -545,9 +564,11 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
545
564
|
# we try to get the price if not provided directly from the underlying instrument
|
|
546
565
|
self.price = self.get_price()
|
|
547
566
|
if self.trade_proposal and not self.portfolio.only_weighting:
|
|
548
|
-
|
|
567
|
+
estimated_shares = self.trade_proposal.get_estimated_shares(
|
|
549
568
|
self.weighting, self.underlying_instrument, self.price
|
|
550
569
|
)
|
|
570
|
+
if estimated_shares:
|
|
571
|
+
self.shares = estimated_shares
|
|
551
572
|
|
|
552
573
|
if not self.custodian and self.bank:
|
|
553
574
|
self.custodian = Custodian.get_by_mapping(self.bank)
|
|
@@ -636,6 +657,10 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
636
657
|
underlying_instrument=self.underlying_instrument.id,
|
|
637
658
|
effective_weight=self._effective_weight,
|
|
638
659
|
target_weight=self._target_weight,
|
|
660
|
+
effective_shares=self._effective_shares,
|
|
661
|
+
target_shares=self._target_shares,
|
|
662
|
+
currency_fx_rate=self.currency_fx_rate,
|
|
663
|
+
price=self.price,
|
|
639
664
|
instrument_type=self.underlying_instrument.security_instrument_type.id,
|
|
640
665
|
currency=self.underlying_instrument.currency,
|
|
641
666
|
date=self.transaction_date,
|
|
@@ -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
|
|
@@ -87,15 +145,16 @@ class TradingService:
|
|
|
87
145
|
for instrument_id, pos in instruments.items():
|
|
88
146
|
if not pos.is_cash:
|
|
89
147
|
effective_weight = target_weight = 0
|
|
90
|
-
effective_shares = 0
|
|
91
|
-
|
|
148
|
+
effective_shares = target_shares = 0
|
|
149
|
+
drift_factor = 1.0
|
|
92
150
|
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
93
151
|
effective_weight = effective_pos.weighting
|
|
94
152
|
effective_shares = effective_pos.shares
|
|
95
|
-
|
|
153
|
+
drift_factor = effective_pos.drift_factor
|
|
96
154
|
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
97
155
|
target_weight = target_pos.weighting
|
|
98
|
-
|
|
156
|
+
if target_pos.shares is not None:
|
|
157
|
+
target_shares = target_pos.shares
|
|
99
158
|
|
|
100
159
|
trades.append(
|
|
101
160
|
Trade(
|
|
@@ -103,9 +162,13 @@ class TradingService:
|
|
|
103
162
|
effective_weight=effective_weight,
|
|
104
163
|
target_weight=target_weight,
|
|
105
164
|
effective_shares=effective_shares,
|
|
165
|
+
target_shares=target_shares,
|
|
106
166
|
date=self.trade_date,
|
|
107
|
-
instrument_type=instrument_type,
|
|
108
|
-
currency=currency,
|
|
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),
|
|
109
172
|
)
|
|
110
173
|
)
|
|
111
174
|
return TradeBatch(tuple(trades))
|
|
@@ -140,3 +203,8 @@ class TradingService:
|
|
|
140
203
|
raise ValidationError(self.errors)
|
|
141
204
|
|
|
142
205
|
return not bool(self._errors)
|
|
206
|
+
|
|
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
|
|
@@ -83,21 +84,23 @@ class Trade:
|
|
|
83
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
|
-
effective_shares: Decimal = None
|
|
91
95
|
is_cash: bool = False
|
|
92
96
|
|
|
93
97
|
def __add__(self, other):
|
|
94
98
|
return Trade(
|
|
95
99
|
underlying_instrument=self.underlying_instrument,
|
|
96
|
-
effective_weight=self.effective_weight
|
|
100
|
+
effective_weight=self.effective_weight,
|
|
97
101
|
target_weight=self.target_weight + other.target_weight,
|
|
98
|
-
effective_shares=self.effective_shares
|
|
99
|
-
|
|
100
|
-
else None,
|
|
102
|
+
effective_shares=self.effective_shares,
|
|
103
|
+
target_shares=self.target_shares + other.target_shares,
|
|
101
104
|
**{
|
|
102
105
|
f.name: getattr(self, f.name)
|
|
103
106
|
for f in fields(Trade)
|
|
@@ -106,15 +109,29 @@ class Trade:
|
|
|
106
109
|
"effective_weight",
|
|
107
110
|
"target_weight",
|
|
108
111
|
"effective_shares",
|
|
112
|
+
"target_shares",
|
|
109
113
|
"underlying_instrument",
|
|
110
114
|
]
|
|
111
115
|
},
|
|
112
116
|
)
|
|
113
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
|
+
|
|
114
123
|
@property
|
|
115
124
|
def delta_weight(self) -> Decimal:
|
|
116
125
|
return self.target_weight - self.effective_weight
|
|
117
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
|
+
|
|
118
135
|
def validate(self):
|
|
119
136
|
return True
|
|
120
137
|
# if self.effective_weight < 0 or self.effective_weight > 1.0:
|
|
@@ -122,17 +139,22 @@ class Trade:
|
|
|
122
139
|
# if self.target_weight < 0 or self.target_weight > 1.0:
|
|
123
140
|
# raise ValidationError("Target Weight needs to be in range [0, 1]")
|
|
124
141
|
|
|
125
|
-
def normalize_target(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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)
|
|
131
153
|
|
|
132
154
|
|
|
133
155
|
@dataclass(frozen=True)
|
|
134
156
|
class TradeBatch:
|
|
135
|
-
trades:
|
|
157
|
+
trades: list[Trade]
|
|
136
158
|
trades_map: dict[Trade] = field(init=False, repr=False)
|
|
137
159
|
|
|
138
160
|
def __post_init__(self):
|
|
@@ -174,9 +196,12 @@ class TradeBatch:
|
|
|
174
196
|
underlying_instrument=trade.underlying_instrument,
|
|
175
197
|
instrument_type=trade.instrument_type,
|
|
176
198
|
weighting=trade.target_weight,
|
|
199
|
+
shares=trade.target_shares,
|
|
177
200
|
currency=trade.currency,
|
|
178
201
|
date=trade.date,
|
|
179
202
|
is_cash=trade.is_cash,
|
|
203
|
+
price=trade.price,
|
|
204
|
+
currency_fx_rate=trade.currency_fx_rate,
|
|
180
205
|
)
|
|
181
206
|
)
|
|
182
207
|
for position in extra_positions:
|
|
@@ -315,8 +315,9 @@ 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
|
|
|
@@ -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
|
|
@@ -267,15 +267,25 @@ class TestTradeProposal:
|
|
|
267
267
|
asset_position_factory.create(
|
|
268
268
|
portfolio=trade_proposal.portfolio, date=effective_date, underlying_instrument=i2, weighting=Decimal("0.3")
|
|
269
269
|
)
|
|
270
|
-
instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
271
|
-
instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
272
|
-
instrument_price_factory.create(instrument=i3, date=effective_date)
|
|
270
|
+
p1 = instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
271
|
+
p2 = instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
272
|
+
p3 = instrument_price_factory.create(instrument=i3, date=effective_date)
|
|
273
273
|
|
|
274
274
|
# build the target portfolio
|
|
275
275
|
target_portfolio = PortfolioDTO(
|
|
276
276
|
positions=(
|
|
277
|
-
Position(
|
|
278
|
-
|
|
277
|
+
Position(
|
|
278
|
+
underlying_instrument=i2.id,
|
|
279
|
+
date=trade_proposal.trade_date,
|
|
280
|
+
weighting=Decimal("0.4"),
|
|
281
|
+
price=float(p2.net_value),
|
|
282
|
+
),
|
|
283
|
+
Position(
|
|
284
|
+
underlying_instrument=i3.id,
|
|
285
|
+
date=trade_proposal.trade_date,
|
|
286
|
+
weighting=Decimal("0.6"),
|
|
287
|
+
price=float(p3.net_value),
|
|
288
|
+
),
|
|
279
289
|
)
|
|
280
290
|
)
|
|
281
291
|
|
|
@@ -295,9 +305,24 @@ class TestTradeProposal:
|
|
|
295
305
|
# build the target portfolio
|
|
296
306
|
new_target_portfolio = PortfolioDTO(
|
|
297
307
|
positions=(
|
|
298
|
-
Position(
|
|
299
|
-
|
|
300
|
-
|
|
308
|
+
Position(
|
|
309
|
+
underlying_instrument=i1.id,
|
|
310
|
+
date=trade_proposal.trade_date,
|
|
311
|
+
weighting=Decimal("0.2"),
|
|
312
|
+
price=float(p1.net_value),
|
|
313
|
+
),
|
|
314
|
+
Position(
|
|
315
|
+
underlying_instrument=i2.id,
|
|
316
|
+
date=trade_proposal.trade_date,
|
|
317
|
+
weighting=Decimal("0.3"),
|
|
318
|
+
price=float(p2.net_value),
|
|
319
|
+
),
|
|
320
|
+
Position(
|
|
321
|
+
underlying_instrument=i3.id,
|
|
322
|
+
date=trade_proposal.trade_date,
|
|
323
|
+
weighting=Decimal("0.5"),
|
|
324
|
+
price=float(p3.net_value),
|
|
325
|
+
),
|
|
301
326
|
)
|
|
302
327
|
)
|
|
303
328
|
|
|
@@ -381,6 +406,8 @@ class TestTradeProposal:
|
|
|
381
406
|
|
|
382
407
|
@patch.object(Portfolio, "get_total_asset_value")
|
|
383
408
|
def test_get_estimated_target_cash(self, mock_fct, trade_proposal, trade_factory, cash_factory):
|
|
409
|
+
trade_proposal.portfolio.only_weighting = False
|
|
410
|
+
trade_proposal.portfolio.save()
|
|
384
411
|
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
385
412
|
cash = cash_factory.create(currency=trade_proposal.portfolio.currency)
|
|
386
413
|
trade_factory.create( # equity trade
|
|
@@ -412,3 +439,191 @@ class TestTradeProposal:
|
|
|
412
439
|
tp2 = trade_proposal_factory.create(portfolio=portfolio, trade_date=tp.trade_date - BDay(1))
|
|
413
440
|
instrument.refresh_from_db()
|
|
414
441
|
assert instrument.inception_date == (tp2.trade_date + BDay(1)).date()
|
|
442
|
+
|
|
443
|
+
def test_get_round_lot_size(self, trade_proposal, instrument):
|
|
444
|
+
# without a round lot size, we expect no normalization of shares
|
|
445
|
+
assert trade_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
446
|
+
instrument.round_lot_size = 100
|
|
447
|
+
instrument.save()
|
|
448
|
+
|
|
449
|
+
# if instrument has a round lot size different than 1, we expect different behavior based on whether shares is positive or negative
|
|
450
|
+
assert trade_proposal.get_round_lot_size(Decimal(66.0), instrument) == Decimal("100")
|
|
451
|
+
assert trade_proposal.get_round_lot_size(Decimal(-66.0), instrument) == Decimal(-66.0)
|
|
452
|
+
assert trade_proposal.get_round_lot_size(Decimal(-120), instrument) == Decimal(-200)
|
|
453
|
+
|
|
454
|
+
# exchange can disable rounding based on the lot size
|
|
455
|
+
instrument.exchange.apply_round_lot_size = False
|
|
456
|
+
instrument.exchange.save()
|
|
457
|
+
assert trade_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
458
|
+
|
|
459
|
+
def test_submit_round_lot_size(self, trade_proposal, trade_factory, instrument):
|
|
460
|
+
trade_proposal.portfolio.only_weighting = False
|
|
461
|
+
trade_proposal.portfolio.save()
|
|
462
|
+
instrument.round_lot_size = 100
|
|
463
|
+
instrument.save()
|
|
464
|
+
trade = trade_factory.create(
|
|
465
|
+
status="DRAFT",
|
|
466
|
+
underlying_instrument=instrument,
|
|
467
|
+
shares=70,
|
|
468
|
+
trade_proposal=trade_proposal,
|
|
469
|
+
weighting=Decimal("1.0"),
|
|
470
|
+
)
|
|
471
|
+
warnings = trade_proposal.submit()
|
|
472
|
+
trade_proposal.save()
|
|
473
|
+
assert (
|
|
474
|
+
len(warnings) == 1
|
|
475
|
+
) # ensure that submit returns a warning concerning the rounded trade based on the lot size
|
|
476
|
+
trade.refresh_from_db()
|
|
477
|
+
assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
|
|
478
|
+
|
|
479
|
+
def test_submit_round_fractional_shares(self, trade_proposal, trade_factory, instrument):
|
|
480
|
+
trade_proposal.portfolio.only_weighting = False
|
|
481
|
+
trade_proposal.portfolio.save()
|
|
482
|
+
trade = trade_factory.create(
|
|
483
|
+
status="DRAFT",
|
|
484
|
+
underlying_instrument=instrument,
|
|
485
|
+
shares=5.6,
|
|
486
|
+
trade_proposal=trade_proposal,
|
|
487
|
+
weighting=Decimal("1.0"),
|
|
488
|
+
)
|
|
489
|
+
trade_proposal.submit()
|
|
490
|
+
trade_proposal.save()
|
|
491
|
+
trade.refresh_from_db()
|
|
492
|
+
assert trade.shares == 6 # we expect the fractional share to be rounded
|
|
493
|
+
|
|
494
|
+
def test_ex_post(
|
|
495
|
+
self, instrument_factory, asset_position_factory, instrument_price_factory, trade_proposal_factory, portfolio
|
|
496
|
+
):
|
|
497
|
+
"""
|
|
498
|
+
Tests the ex-post rebalancing mechanism of a portfolio with two instruments.
|
|
499
|
+
Verifies that weights are correctly recalculated after submitting and approving a trade proposal.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
# --- Create instruments ---
|
|
503
|
+
msft = instrument_factory.create(currency=portfolio.currency)
|
|
504
|
+
apple = instrument_factory.create(currency=portfolio.currency)
|
|
505
|
+
|
|
506
|
+
# --- Key dates ---
|
|
507
|
+
d1 = date(2025, 6, 24)
|
|
508
|
+
d2 = date(2025, 6, 25)
|
|
509
|
+
d3 = date(2025, 6, 26)
|
|
510
|
+
d4 = date(2025, 6, 27)
|
|
511
|
+
|
|
512
|
+
# --- Create MSFT prices ---
|
|
513
|
+
msft_p1 = instrument_price_factory.create(instrument=msft, date=d1, net_value=Decimal("10"))
|
|
514
|
+
msft_p2 = instrument_price_factory.create(instrument=msft, date=d2, net_value=Decimal("8"))
|
|
515
|
+
msft_p3 = instrument_price_factory.create(instrument=msft, date=d3, net_value=Decimal("12"))
|
|
516
|
+
msft_p4 = instrument_price_factory.create(instrument=msft, date=d4, net_value=Decimal("15")) # noqa
|
|
517
|
+
|
|
518
|
+
# Calculate MSFT returns between dates
|
|
519
|
+
msft_r2 = msft_p2.net_value / msft_p1.net_value - Decimal("1") # noqa
|
|
520
|
+
msft_r3 = msft_p3.net_value / msft_p2.net_value - Decimal("1")
|
|
521
|
+
|
|
522
|
+
# --- Create Apple prices (stable) ---
|
|
523
|
+
apple_p1 = instrument_price_factory.create(instrument=apple, date=d1, net_value=Decimal("100"))
|
|
524
|
+
apple_p2 = instrument_price_factory.create(instrument=apple, date=d2, net_value=Decimal("100"))
|
|
525
|
+
apple_p3 = instrument_price_factory.create(instrument=apple, date=d3, net_value=Decimal("100"))
|
|
526
|
+
apple_p4 = instrument_price_factory.create(instrument=apple, date=d4, net_value=Decimal("100")) # noqa
|
|
527
|
+
|
|
528
|
+
# Apple returns (always 0 since price is stable)
|
|
529
|
+
apple_r2 = apple_p2.net_value / apple_p1.net_value - Decimal("1") # noqa
|
|
530
|
+
apple_r3 = apple_p3.net_value / apple_p2.net_value - Decimal("1")
|
|
531
|
+
|
|
532
|
+
# --- Create positions on d2 ---
|
|
533
|
+
msft_a2 = asset_position_factory.create(
|
|
534
|
+
portfolio=portfolio,
|
|
535
|
+
underlying_quote=msft,
|
|
536
|
+
date=d2,
|
|
537
|
+
initial_shares=10,
|
|
538
|
+
weighting=Decimal("0.44"),
|
|
539
|
+
)
|
|
540
|
+
apple_a2 = asset_position_factory.create(
|
|
541
|
+
portfolio=portfolio,
|
|
542
|
+
underlying_quote=apple,
|
|
543
|
+
date=d2,
|
|
544
|
+
initial_shares=1,
|
|
545
|
+
weighting=Decimal("0.56"),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Check that initial weights sum to 1
|
|
549
|
+
total_weight_d2 = msft_a2.weighting + apple_a2.weighting
|
|
550
|
+
assert total_weight_d2 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
551
|
+
|
|
552
|
+
# --- Calculate total portfolio return between d2 and d3 ---
|
|
553
|
+
portfolio_r3 = msft_a2.weighting * (Decimal("1.0") + msft_r3) + apple_a2.weighting * (
|
|
554
|
+
Decimal("1.0") + apple_r3
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# --- Create positions on d3 with weights adjusted for returns ---
|
|
558
|
+
msft_a3 = asset_position_factory.create(
|
|
559
|
+
portfolio=portfolio,
|
|
560
|
+
underlying_quote=msft,
|
|
561
|
+
date=d3,
|
|
562
|
+
initial_shares=10,
|
|
563
|
+
weighting=msft_a2.weighting * (Decimal("1.0") + msft_r3) / portfolio_r3,
|
|
564
|
+
)
|
|
565
|
+
apple_a3 = asset_position_factory.create(
|
|
566
|
+
portfolio=portfolio,
|
|
567
|
+
underlying_quote=apple,
|
|
568
|
+
date=d3,
|
|
569
|
+
initial_shares=1,
|
|
570
|
+
weighting=apple_a2.weighting * (Decimal("1.0") + apple_r3) / portfolio_r3,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Check that weights on d3 sum to 1
|
|
574
|
+
total_weight_d3 = msft_a3.weighting + apple_a3.weighting
|
|
575
|
+
assert total_weight_d3 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
576
|
+
|
|
577
|
+
# --- Create a trade proposal on d3 ---
|
|
578
|
+
trade_proposal = trade_proposal_factory.create(portfolio=portfolio, trade_date=d3)
|
|
579
|
+
trade_proposal.reset_trades()
|
|
580
|
+
|
|
581
|
+
# Retrieve trades for each instrument
|
|
582
|
+
trade_msft = trade_proposal.trades.get(underlying_instrument=msft)
|
|
583
|
+
trade_apple = trade_proposal.trades.get(underlying_instrument=apple)
|
|
584
|
+
|
|
585
|
+
# Check that trade weights are initially zero
|
|
586
|
+
assert trade_msft.weighting == Decimal("0")
|
|
587
|
+
assert trade_apple.weighting == Decimal("0")
|
|
588
|
+
|
|
589
|
+
# --- Adjust trade weights to target 50% each ---
|
|
590
|
+
target_weight = Decimal("0.5")
|
|
591
|
+
trade_msft.weighting = target_weight - msft_a3.weighting
|
|
592
|
+
trade_msft.save()
|
|
593
|
+
|
|
594
|
+
trade_apple.weighting = target_weight - apple_a3.weighting
|
|
595
|
+
trade_apple.save()
|
|
596
|
+
|
|
597
|
+
# --- Check drift factors and effective weights ---
|
|
598
|
+
assert trade_msft.drift_factor == pytest.approx(msft_a3.weighting / msft_a2.weighting, abs=Decimal("1e-6"))
|
|
599
|
+
assert trade_apple.drift_factor == pytest.approx(apple_a3.weighting / apple_a2.weighting, abs=Decimal("1e-6"))
|
|
600
|
+
|
|
601
|
+
assert trade_msft._effective_weight == pytest.approx(msft_a3.weighting, abs=Decimal("1e-6"))
|
|
602
|
+
assert trade_apple._effective_weight == pytest.approx(apple_a3.weighting, abs=Decimal("1e-6"))
|
|
603
|
+
|
|
604
|
+
# Check that the target weight is the sum of drifted weight and adjustment
|
|
605
|
+
assert trade_msft._target_weight == pytest.approx(
|
|
606
|
+
msft_a2.weighting * trade_msft.drift_factor + trade_msft.weighting,
|
|
607
|
+
abs=Decimal("1e-6"),
|
|
608
|
+
)
|
|
609
|
+
assert trade_msft._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
610
|
+
|
|
611
|
+
assert trade_apple._target_weight == pytest.approx(
|
|
612
|
+
apple_a2.weighting * trade_apple.drift_factor + trade_apple.weighting,
|
|
613
|
+
abs=Decimal("1e-6"),
|
|
614
|
+
)
|
|
615
|
+
assert trade_apple._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
616
|
+
|
|
617
|
+
# --- Submit and approve the trade proposal ---
|
|
618
|
+
trade_proposal.submit()
|
|
619
|
+
trade_proposal.save()
|
|
620
|
+
trade_proposal.approve()
|
|
621
|
+
trade_proposal.save()
|
|
622
|
+
|
|
623
|
+
# --- Refresh positions after ex-post rebalancing ---
|
|
624
|
+
msft_a3.refresh_from_db()
|
|
625
|
+
apple_a3.refresh_from_db()
|
|
626
|
+
|
|
627
|
+
# Final check that weights have been updated to 50%
|
|
628
|
+
assert msft_a3.weighting == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
629
|
+
assert apple_a3.weighting == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
@@ -526,4 +526,6 @@ class TradeTradeProposalModelViewSet(
|
|
|
526
526
|
effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
|
|
527
527
|
target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
|
|
528
528
|
portfolio_currency=F("portfolio__currency__symbol"),
|
|
529
|
+
security=F("underlying_instrument__parent"),
|
|
530
|
+
company=F("underlying_instrument__parent__parent"),
|
|
529
531
|
)
|
|
@@ -63,7 +63,7 @@ wbportfolio/factories/indexes.py,sha256=sInyNARyfUNbScxr_ng1HhxtJXIsK3awFf9BF1gr
|
|
|
63
63
|
wbportfolio/factories/portfolio_cash_flow.py,sha256=e8LWkk5Gy0FU4HoCL9xPseCtLlVIkMrvAmey5swqcfc,727
|
|
64
64
|
wbportfolio/factories/portfolio_cash_targets.py,sha256=6bYaKjV56yRbjRaQzime218Dho652pcpkAqwhq63D3g,678
|
|
65
65
|
wbportfolio/factories/portfolio_swing_pricings.py,sha256=CLT9AgZKPwChfPDATl-r2r8IVsM5U6S5gDJ8BzsweyI,828
|
|
66
|
-
wbportfolio/factories/portfolios.py,sha256=
|
|
66
|
+
wbportfolio/factories/portfolios.py,sha256=zjjbb81ZokA7vJ7SXFDOmiNqP3Aq6-ZEh2gPiaYKfkY,1550
|
|
67
67
|
wbportfolio/factories/product_groups.py,sha256=JMtG301j5XehB3qmNSbXt6af56Y9uvG3fgbCMf9FCSc,1987
|
|
68
68
|
wbportfolio/factories/products.py,sha256=N0V6sM_98sC6l8qN78wgfmtpYq7O2c5rW5hT5ZM5gsA,2880
|
|
69
69
|
wbportfolio/factories/rebalancing.py,sha256=5wlB5cT65oBFcMQDLyMn50JOJMD7GQvzpr9Pe0cqJLE,721
|
|
@@ -247,14 +247,15 @@ wbportfolio/migrations/0074_alter_rebalancer_frequency_and_more.py,sha256=o01rBj
|
|
|
247
247
|
wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py,sha256=rXRpsraVlmueAlO2UpBZV4qMf7dtPuptrhfLblZcJDo,1099
|
|
248
248
|
wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py,sha256=4g3ok79nw8mTAxHFAqBAdpGKetaPAjv06YSywywt4aU,6106
|
|
249
249
|
wbportfolio/migrations/0077_remove_transaction_currency_and_more.py,sha256=Yf4a3zn2siDtWdIEPEIsj_W87jxOIBwiFVATneU8FxU,29597
|
|
250
|
+
wbportfolio/migrations/0078_trade_drift_factor.py,sha256=26Z3yoiBhMueB-k2R9HaIzg5Qr7BYpdtzlU-65T_cH0,999
|
|
250
251
|
wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
251
252
|
wbportfolio/models/__init__.py,sha256=HSpa5xwh_MHQaBpNrq9E0CbdEE5Iq-pDLIsPzZ-TRTg,904
|
|
252
253
|
wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
|
|
253
|
-
wbportfolio/models/asset.py,sha256=
|
|
254
|
+
wbportfolio/models/asset.py,sha256=9wD7d8rfK8MEogj0WgaZud9wQs5JGPR61gDQRU3W5as,45522
|
|
254
255
|
wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
|
|
255
256
|
wbportfolio/models/exceptions.py,sha256=3ix0tWUO-O6jpz8f07XIwycw2x3JFRoWzjwil8FVA2Q,52
|
|
256
257
|
wbportfolio/models/indexes.py,sha256=gvW4K9U9Bj8BmVCqFYdWiXvDWhjHINRON8XhNsZUiQY,639
|
|
257
|
-
wbportfolio/models/portfolio.py,sha256=
|
|
258
|
+
wbportfolio/models/portfolio.py,sha256=evF-_6lhVdo-fs0Y9-J6EQYO8HlOEHoayrALVbT6UgY,56822
|
|
258
259
|
wbportfolio/models/portfolio_cash_flow.py,sha256=uElG7IJUBY8qvtrXftOoskX6EA-dKgEG1JJdvHeWV7g,7336
|
|
259
260
|
wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
|
|
260
261
|
wbportfolio/models/portfolio_relationship.py,sha256=ZGECiPZiLdlk4uSamOrEfuzO0hduK6OMKJLUSnh5_kc,5190
|
|
@@ -281,16 +282,16 @@ wbportfolio/models/transactions/claim.py,sha256=SF2FlwG6SRVmA_hT0NbXah5-fYejccWK
|
|
|
281
282
|
wbportfolio/models/transactions/dividends.py,sha256=mmOdGWR35yndUMoCuG24Y6BdtxDhSk2gMQ-8LVguqzg,1890
|
|
282
283
|
wbportfolio/models/transactions/fees.py,sha256=wJtlzbBCAq1UHvv0wqWTE2BEjCF5RMtoaSDS3kODFRo,7112
|
|
283
284
|
wbportfolio/models/transactions/rebalancing.py,sha256=obzgewWKOD4kJbCoF5fhtfDk502QkbrjPKh8T9KDGew,7355
|
|
284
|
-
wbportfolio/models/transactions/trade_proposals.py,sha256=
|
|
285
|
-
wbportfolio/models/transactions/trades.py,sha256=
|
|
285
|
+
wbportfolio/models/transactions/trade_proposals.py,sha256=ikc3QMSBBWcBwCdl67_G-OomHuC-Tb9I-KttKkbgWwc,32399
|
|
286
|
+
wbportfolio/models/transactions/trades.py,sha256=gJdJ4fcqBR73XzNPYg6mPGMgk4z8DAtcgkUSPGcj5LM,33323
|
|
286
287
|
wbportfolio/models/transactions/transactions.py,sha256=XTcUeMUfkf5XTSZaR2UAyGqCVkOhQYk03_vzHLIgf8Q,3807
|
|
287
288
|
wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
288
|
-
wbportfolio/pms/typing.py,sha256=
|
|
289
|
+
wbportfolio/pms/typing.py,sha256=izDCw5g6AUpqXriDDWa9KHpx7D7PSGLddBqhzXgryNQ,7875
|
|
289
290
|
wbportfolio/pms/analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
290
291
|
wbportfolio/pms/analytics/portfolio.py,sha256=vE0KA6Z037bUdmBTkYuBqXElt80nuYObNzY_kWvxEZY,1360
|
|
291
292
|
wbportfolio/pms/statistics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
292
293
|
wbportfolio/pms/trading/__init__.py,sha256=R_yLKc54sCak8A1cW0O1Aszrcv5KV8mC_3h17Hr20e4,36
|
|
293
|
-
wbportfolio/pms/trading/handler.py,sha256=
|
|
294
|
+
wbportfolio/pms/trading/handler.py,sha256=kKUPuZ7bVmvzVteVQWPsmcPxBHpUAjRgO66P5Lfi7NY,8619
|
|
294
295
|
wbportfolio/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
295
296
|
wbportfolio/rebalancing/base.py,sha256=NwTGZtBm1f35gj5Jp6iTyyFvDT1GSIztN990cKBvYzQ,637
|
|
296
297
|
wbportfolio/rebalancing/decorators.py,sha256=JhQ2vkcIuGBTGvNmpkQerdw2-vLq-RAb0KAPjOrETkw,515
|
|
@@ -347,7 +348,7 @@ wbportfolio/serializers/transactions/claim.py,sha256=kC4E2RZRrpd9i8tGfoiV-gpWDk3
|
|
|
347
348
|
wbportfolio/serializers/transactions/dividends.py,sha256=ADXf9cXe8rq55lC_a8vIzViGLmQ-yDXkgR54k2m-N0w,1814
|
|
348
349
|
wbportfolio/serializers/transactions/fees.py,sha256=3mBzs6vdfST9roeQB-bmLJhipY7i5jBtAXjoTTE-GOg,2388
|
|
349
350
|
wbportfolio/serializers/transactions/trade_proposals.py,sha256=ufYBYiSttz5KAlAaaXbnf98oUFT_qNfxF_VUM4ClXE8,4072
|
|
350
|
-
wbportfolio/serializers/transactions/trades.py,sha256=
|
|
351
|
+
wbportfolio/serializers/transactions/trades.py,sha256=YVccQpP480P4-0uVaRfnmpPFoIdW2U0c92kJBR_fPLo,16889
|
|
351
352
|
wbportfolio/static/wbportfolio/css/macro_review.css,sha256=FAVVO8nModxwPXcTKpcfzVxBGPZGJVK1Xn-0dkSfGyc,233
|
|
352
353
|
wbportfolio/static/wbportfolio/markdown/documentation/account_holding_reconciliation.md,sha256=MabxOvOne8s5gl6osDoow6-3ghaXLAYg9THWpvy6G5I,921
|
|
353
354
|
wbportfolio/static/wbportfolio/markdown/documentation/aggregate_asset_position_liquidity.md,sha256=HEgXB7uqmqfty-GBCCXYxrAN-teqmxWuqDLK_liKWVc,1090
|
|
@@ -377,7 +378,7 @@ wbportfolio/tests/models/test_merge.py,sha256=sdsjiZsmR6vsUKwTa5kkvL6QTeAZqtd_EP
|
|
|
377
378
|
wbportfolio/tests/models/test_portfolio_cash_flow.py,sha256=X8dsXexsb1b0lBiuGzu40ps_Az_1UmmKT0eo1vbXH94,5792
|
|
378
379
|
wbportfolio/tests/models/test_portfolio_cash_targets.py,sha256=q8QWAwt-kKRkLC0E05GyRhF_TTQXIi8bdHjXVU0fCV0,965
|
|
379
380
|
wbportfolio/tests/models/test_portfolio_swing_pricings.py,sha256=kr2AOcQkyg2pX3ULjU-o9ye-NVpjMrrfoe-DVbYCbjs,1656
|
|
380
|
-
wbportfolio/tests/models/test_portfolios.py,sha256=
|
|
381
|
+
wbportfolio/tests/models/test_portfolios.py,sha256=L_NTLYdLgKkllXlnjX8aBOFl95zBJq8gjQySuhQG1T0,53320
|
|
381
382
|
wbportfolio/tests/models/test_product_groups.py,sha256=AcdxhurV-n_bBuUsfD1GqVtwLFcs7VI2CRrwzsIUWbU,3337
|
|
382
383
|
wbportfolio/tests/models/test_products.py,sha256=IcBzw9hrGiWFMRwPBTMukCMWrhqnjOVA2hhb90xYOW8,9580
|
|
383
384
|
wbportfolio/tests/models/test_roles.py,sha256=4Cn7WyrA2ztJNeWLk5cy9kYo5XLWMbFSvo1O-9JYxeA,3323
|
|
@@ -387,7 +388,7 @@ wbportfolio/tests/models/transactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
|
|
|
387
388
|
wbportfolio/tests/models/transactions/test_claim.py,sha256=NG3BKB-FVcIDgHSJHCjImxgMM3ISVUMl24xUPmEcPec,5570
|
|
388
389
|
wbportfolio/tests/models/transactions/test_fees.py,sha256=tAp18x2wCNQr11LUnLtHNbBDbbX0v1DZnmW7i-cEi5Q,2423
|
|
389
390
|
wbportfolio/tests/models/transactions/test_rebalancing.py,sha256=fZ5tx6kByEGXD6nhapYdvk9HOjYlmjhU2w6KlQJ6QE4,4061
|
|
390
|
-
wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=
|
|
391
|
+
wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=eXSs_xjmfdmmkKy6_MMWpyzir6j2FIhKsBQUpzrHNMo,28370
|
|
391
392
|
wbportfolio/tests/models/transactions/test_trades.py,sha256=vqvOqUY_uXvBp8YOKR0Wq9ycA2oeeEBhO3dzV7sbXEU,9863
|
|
392
393
|
wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
393
394
|
wbportfolio/tests/pms/test_analytics.py,sha256=fAuY1zcXibttFpBh2GhKVyzdYfi1kz_b7SPa9xZQXY0,1086
|
|
@@ -518,8 +519,8 @@ wbportfolio/viewsets/transactions/fees.py,sha256=WT2bWWfgozz4_rpyTKX7dgBBTXD-gu0
|
|
|
518
519
|
wbportfolio/viewsets/transactions/mixins.py,sha256=WipvJoi5hylkpD0y9VATe30WAcwIHUIroVkK10FYw7k,636
|
|
519
520
|
wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQVTN2MH1kG_yCeVkmyK8k,1263
|
|
520
521
|
wbportfolio/viewsets/transactions/trade_proposals.py,sha256=zdb9yqyAE1-hcMmxxN3CYePWoPVWIW3WP3v8agErDeY,6210
|
|
521
|
-
wbportfolio/viewsets/transactions/trades.py,sha256=
|
|
522
|
-
wbportfolio-1.
|
|
523
|
-
wbportfolio-1.
|
|
524
|
-
wbportfolio-1.
|
|
525
|
-
wbportfolio-1.
|
|
522
|
+
wbportfolio/viewsets/transactions/trades.py,sha256=GHOw5jtcqoaHiRrxxxL29c9405QiPisEn4coGELKDrE,22146
|
|
523
|
+
wbportfolio-1.53.0.dist-info/METADATA,sha256=OWTBERfDBEZ3lznF4KBNs4pV6Y9hyoKlrLTHZjawV_E,702
|
|
524
|
+
wbportfolio-1.53.0.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
|
|
525
|
+
wbportfolio-1.53.0.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
|
|
526
|
+
wbportfolio-1.53.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|