wbportfolio 1.52.6__py2.py3-none-any.whl → 1.54.1__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.

@@ -19,6 +19,7 @@ class PortfolioFactory(factory.django.DjangoModelFactory):
19
19
  is_manageable = True
20
20
  is_tracked = True
21
21
  is_lookthrough = False
22
+ only_weighting = True
22
23
  invested_timespan = DateRange(date.min, date.max)
23
24
 
24
25
  @factory.post_generation
@@ -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
+ ]
@@ -0,0 +1,19 @@
1
+ # Generated by Django 5.0.14 on 2025-07-08 08:11
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', '0078_trade_drift_factor'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterField(
15
+ model_name='trade',
16
+ name='drift_factor',
17
+ field=models.DecimalField(decimal_places=8, 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
+ ]
@@ -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
@@ -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([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_kwargs)]),
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.portfolio._build_dto(self.last_effective_date),
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.portfolio._build_dto(self.last_effective_date),
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=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(by=by, description=description, **kwargs)
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
- effective_weight=Coalesce(
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=16,
167
- decimal_places=6,
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=8,
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
- errors = []
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
- errors.append(
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 errors
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
- return getattr(
461
- self,
462
- "effective_weight",
463
- AssetPosition.unannotated_objects.filter(
464
- underlying_quote=self.underlying_instrument,
465
- date=self._last_effective_date,
466
- portfolio=self.portfolio,
467
- ).aggregate(s=Sum("weighting"))["s"]
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
- self.shares = self.trade_proposal.get_estimated_shares(
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
- instrument_type = currency = None
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
- instrument_type, currency = effective_pos.instrument_type, effective_pos.currency
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
- instrument_type, currency = target_pos.instrument_type, target_pos.currency
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 + other.effective_weight,
100
+ effective_weight=self.effective_weight,
97
101
  target_weight=self.target_weight + other.target_weight,
98
- effective_shares=self.effective_shares + other.effective_shares
99
- if (self.effective_shares is not None and other.effective_shares is not None)
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(self, factor: Decimal):
126
- t = Trade(
127
- target_weight=self.target_weight * factor,
128
- **{f.name: getattr(self, f.name) for f in fields(Trade) if f.name not in ["target_weight"]},
129
- )
130
- return t
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: tuple[Trade]
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
- target_weight = wb_serializers.DecimalField(max_digits=16, decimal_places=6, required=False, default=0)
319
- effective_weight = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
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(underlying_instrument=i2.id, date=trade_proposal.trade_date, weighting=Decimal("0.4")),
278
- Position(underlying_instrument=i3.id, date=trade_proposal.trade_date, weighting=Decimal("0.6")),
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(underlying_instrument=i1.id, date=trade_proposal.trade_date, weighting=Decimal("0.2")),
299
- Position(underlying_instrument=i2.id, date=trade_proposal.trade_date, weighting=Decimal("0.3")),
300
- Position(underlying_instrument=i3.id, date=trade_proposal.trade_date, weighting=Decimal("0.5")),
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbportfolio
3
- Version: 1.52.6
3
+ Version: 1.54.1
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  License-File: LICENSE
6
6
  Requires-Dist: cryptography==3.4.*
@@ -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=3_wzz-q7Tc3CtVOQNM8eA4UZljds2IizTzGWE9ue1AQ,1524
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,16 @@ 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
251
+ wbportfolio/migrations/0079_alter_trade_drift_factor.py,sha256=2tvPecUxEy60-ELy9wtiuTR2dhJ8HucJjvOEuia4Pp4,627
250
252
  wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
251
253
  wbportfolio/models/__init__.py,sha256=HSpa5xwh_MHQaBpNrq9E0CbdEE5Iq-pDLIsPzZ-TRTg,904
252
254
  wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
253
- wbportfolio/models/asset.py,sha256=WwbLjpGHzz17yJxrkuTP4MbErqcTJMlKkDrQI92p5II,45490
255
+ wbportfolio/models/asset.py,sha256=9wD7d8rfK8MEogj0WgaZud9wQs5JGPR61gDQRU3W5as,45522
254
256
  wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
255
257
  wbportfolio/models/exceptions.py,sha256=3ix0tWUO-O6jpz8f07XIwycw2x3JFRoWzjwil8FVA2Q,52
256
258
  wbportfolio/models/indexes.py,sha256=gvW4K9U9Bj8BmVCqFYdWiXvDWhjHINRON8XhNsZUiQY,639
257
- wbportfolio/models/portfolio.py,sha256=yQ8cqAZLOMwsiejC9RzOLoqLDV5q5ZWYWMOc2BlcHdw,56281
259
+ wbportfolio/models/portfolio.py,sha256=evF-_6lhVdo-fs0Y9-J6EQYO8HlOEHoayrALVbT6UgY,56822
258
260
  wbportfolio/models/portfolio_cash_flow.py,sha256=uElG7IJUBY8qvtrXftOoskX6EA-dKgEG1JJdvHeWV7g,7336
259
261
  wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
260
262
  wbportfolio/models/portfolio_relationship.py,sha256=ZGECiPZiLdlk4uSamOrEfuzO0hduK6OMKJLUSnh5_kc,5190
@@ -281,16 +283,16 @@ wbportfolio/models/transactions/claim.py,sha256=SF2FlwG6SRVmA_hT0NbXah5-fYejccWK
281
283
  wbportfolio/models/transactions/dividends.py,sha256=mmOdGWR35yndUMoCuG24Y6BdtxDhSk2gMQ-8LVguqzg,1890
282
284
  wbportfolio/models/transactions/fees.py,sha256=wJtlzbBCAq1UHvv0wqWTE2BEjCF5RMtoaSDS3kODFRo,7112
283
285
  wbportfolio/models/transactions/rebalancing.py,sha256=obzgewWKOD4kJbCoF5fhtfDk502QkbrjPKh8T9KDGew,7355
284
- wbportfolio/models/transactions/trade_proposals.py,sha256=Jd7rfiE42Qtqp9VXQ448OF9m_DKoFR8ISjSh-1TDi8o,31592
285
- wbportfolio/models/transactions/trades.py,sha256=og8R4Sg7LCRQsuX09uYjZXq9q4VsUb0j8W8iCTyx0pQ,31571
286
+ wbportfolio/models/transactions/trade_proposals.py,sha256=ikc3QMSBBWcBwCdl67_G-OomHuC-Tb9I-KttKkbgWwc,32399
287
+ wbportfolio/models/transactions/trades.py,sha256=x-wy3Ak-XYvAv0XVw9fZKTx6g8iai7CtEPCrsRsvwuU,33323
286
288
  wbportfolio/models/transactions/transactions.py,sha256=XTcUeMUfkf5XTSZaR2UAyGqCVkOhQYk03_vzHLIgf8Q,3807
287
289
  wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
288
- wbportfolio/pms/typing.py,sha256=qcqEs94HA7qNB631LErV7FVQ4ytYBLetuPDcilrnlW0,6798
290
+ wbportfolio/pms/typing.py,sha256=izDCw5g6AUpqXriDDWa9KHpx7D7PSGLddBqhzXgryNQ,7875
289
291
  wbportfolio/pms/analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
290
292
  wbportfolio/pms/analytics/portfolio.py,sha256=vE0KA6Z037bUdmBTkYuBqXElt80nuYObNzY_kWvxEZY,1360
291
293
  wbportfolio/pms/statistics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
292
294
  wbportfolio/pms/trading/__init__.py,sha256=R_yLKc54sCak8A1cW0O1Aszrcv5KV8mC_3h17Hr20e4,36
293
- wbportfolio/pms/trading/handler.py,sha256=ORvtwAG2VX1GvNvNE2HnDvS-4UNrgBwCMPydnfphtY0,5992
295
+ wbportfolio/pms/trading/handler.py,sha256=kKUPuZ7bVmvzVteVQWPsmcPxBHpUAjRgO66P5Lfi7NY,8619
294
296
  wbportfolio/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
295
297
  wbportfolio/rebalancing/base.py,sha256=NwTGZtBm1f35gj5Jp6iTyyFvDT1GSIztN990cKBvYzQ,637
296
298
  wbportfolio/rebalancing/decorators.py,sha256=JhQ2vkcIuGBTGvNmpkQerdw2-vLq-RAb0KAPjOrETkw,515
@@ -347,7 +349,7 @@ wbportfolio/serializers/transactions/claim.py,sha256=kC4E2RZRrpd9i8tGfoiV-gpWDk3
347
349
  wbportfolio/serializers/transactions/dividends.py,sha256=ADXf9cXe8rq55lC_a8vIzViGLmQ-yDXkgR54k2m-N0w,1814
348
350
  wbportfolio/serializers/transactions/fees.py,sha256=3mBzs6vdfST9roeQB-bmLJhipY7i5jBtAXjoTTE-GOg,2388
349
351
  wbportfolio/serializers/transactions/trade_proposals.py,sha256=ufYBYiSttz5KAlAaaXbnf98oUFT_qNfxF_VUM4ClXE8,4072
350
- wbportfolio/serializers/transactions/trades.py,sha256=JtL2jrvIjBVsmB2N5ngw_1UyzK7BbxGK5yuNTCRSfkU,16815
352
+ wbportfolio/serializers/transactions/trades.py,sha256=YVccQpP480P4-0uVaRfnmpPFoIdW2U0c92kJBR_fPLo,16889
351
353
  wbportfolio/static/wbportfolio/css/macro_review.css,sha256=FAVVO8nModxwPXcTKpcfzVxBGPZGJVK1Xn-0dkSfGyc,233
352
354
  wbportfolio/static/wbportfolio/markdown/documentation/account_holding_reconciliation.md,sha256=MabxOvOne8s5gl6osDoow6-3ghaXLAYg9THWpvy6G5I,921
353
355
  wbportfolio/static/wbportfolio/markdown/documentation/aggregate_asset_position_liquidity.md,sha256=HEgXB7uqmqfty-GBCCXYxrAN-teqmxWuqDLK_liKWVc,1090
@@ -377,7 +379,7 @@ wbportfolio/tests/models/test_merge.py,sha256=sdsjiZsmR6vsUKwTa5kkvL6QTeAZqtd_EP
377
379
  wbportfolio/tests/models/test_portfolio_cash_flow.py,sha256=X8dsXexsb1b0lBiuGzu40ps_Az_1UmmKT0eo1vbXH94,5792
378
380
  wbportfolio/tests/models/test_portfolio_cash_targets.py,sha256=q8QWAwt-kKRkLC0E05GyRhF_TTQXIi8bdHjXVU0fCV0,965
379
381
  wbportfolio/tests/models/test_portfolio_swing_pricings.py,sha256=kr2AOcQkyg2pX3ULjU-o9ye-NVpjMrrfoe-DVbYCbjs,1656
380
- wbportfolio/tests/models/test_portfolios.py,sha256=ECUAxE07KwmlHN1udMHVCmNDQrRcCbMfRQe8L3cHOX0,53111
382
+ wbportfolio/tests/models/test_portfolios.py,sha256=L_NTLYdLgKkllXlnjX8aBOFl95zBJq8gjQySuhQG1T0,53320
381
383
  wbportfolio/tests/models/test_product_groups.py,sha256=AcdxhurV-n_bBuUsfD1GqVtwLFcs7VI2CRrwzsIUWbU,3337
382
384
  wbportfolio/tests/models/test_products.py,sha256=IcBzw9hrGiWFMRwPBTMukCMWrhqnjOVA2hhb90xYOW8,9580
383
385
  wbportfolio/tests/models/test_roles.py,sha256=4Cn7WyrA2ztJNeWLk5cy9kYo5XLWMbFSvo1O-9JYxeA,3323
@@ -387,7 +389,7 @@ wbportfolio/tests/models/transactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
387
389
  wbportfolio/tests/models/transactions/test_claim.py,sha256=NG3BKB-FVcIDgHSJHCjImxgMM3ISVUMl24xUPmEcPec,5570
388
390
  wbportfolio/tests/models/transactions/test_fees.py,sha256=tAp18x2wCNQr11LUnLtHNbBDbbX0v1DZnmW7i-cEi5Q,2423
389
391
  wbportfolio/tests/models/transactions/test_rebalancing.py,sha256=fZ5tx6kByEGXD6nhapYdvk9HOjYlmjhU2w6KlQJ6QE4,4061
390
- wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=ERGDbniHctEnIG5yFLkrZX2Jw1jEew5rWceVKuKskto,18997
392
+ wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=eXSs_xjmfdmmkKy6_MMWpyzir6j2FIhKsBQUpzrHNMo,28370
391
393
  wbportfolio/tests/models/transactions/test_trades.py,sha256=vqvOqUY_uXvBp8YOKR0Wq9ycA2oeeEBhO3dzV7sbXEU,9863
392
394
  wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
393
395
  wbportfolio/tests/pms/test_analytics.py,sha256=fAuY1zcXibttFpBh2GhKVyzdYfi1kz_b7SPa9xZQXY0,1086
@@ -518,8 +520,8 @@ wbportfolio/viewsets/transactions/fees.py,sha256=WT2bWWfgozz4_rpyTKX7dgBBTXD-gu0
518
520
  wbportfolio/viewsets/transactions/mixins.py,sha256=WipvJoi5hylkpD0y9VATe30WAcwIHUIroVkK10FYw7k,636
519
521
  wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQVTN2MH1kG_yCeVkmyK8k,1263
520
522
  wbportfolio/viewsets/transactions/trade_proposals.py,sha256=zdb9yqyAE1-hcMmxxN3CYePWoPVWIW3WP3v8agErDeY,6210
521
- wbportfolio/viewsets/transactions/trades.py,sha256=DcfiJReBnnR6oBgNKZyBYWhXurtxJG_DCzxq5WCionA,22025
522
- wbportfolio-1.52.6.dist-info/METADATA,sha256=9YI1D7f4Omg3dI2yJs8v_cUOHJ4cdzujpm3l7QfGh38,702
523
- wbportfolio-1.52.6.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
524
- wbportfolio-1.52.6.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
525
- wbportfolio-1.52.6.dist-info/RECORD,,
523
+ wbportfolio/viewsets/transactions/trades.py,sha256=GHOw5jtcqoaHiRrxxxL29c9405QiPisEn4coGELKDrE,22146
524
+ wbportfolio-1.54.1.dist-info/METADATA,sha256=e7bohc0HnRhd7b5r86W16YcAXZjR0h2x698-uBl64yw,702
525
+ wbportfolio-1.54.1.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
526
+ wbportfolio-1.54.1.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
527
+ wbportfolio-1.54.1.dist-info/RECORD,,