wbportfolio 1.52.5__py2.py3-none-any.whl → 1.52.6__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/models/asset.py +1 -1
- wbportfolio/models/portfolio.py +1 -1
- wbportfolio/models/transactions/trade_proposals.py +59 -68
- wbportfolio/models/transactions/trades.py +26 -17
- wbportfolio/pms/trading/handler.py +37 -56
- wbportfolio/pms/typing.py +27 -9
- wbportfolio/rebalancing/models/model_portfolio.py +11 -14
- wbportfolio/serializers/transactions/trade_proposals.py +18 -1
- wbportfolio/serializers/transactions/trades.py +30 -3
- wbportfolio/tests/models/transactions/test_trade_proposals.py +7 -5
- wbportfolio/tests/rebalancing/test_models.py +10 -16
- wbportfolio/viewsets/configs/buttons/trade_proposals.py +9 -1
- wbportfolio/viewsets/configs/display/trade_proposals.py +5 -4
- wbportfolio/viewsets/configs/display/trades.py +36 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +3 -1
- wbportfolio/viewsets/transactions/trades.py +51 -45
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.52.6.dist-info}/METADATA +1 -1
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.52.6.dist-info}/RECORD +20 -20
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.52.6.dist-info}/WHEEL +0 -0
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.52.6.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/asset.py
CHANGED
|
@@ -708,7 +708,7 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
708
708
|
instrument_type=self.underlying_quote.security_instrument_type.id,
|
|
709
709
|
currency=self.underlying_quote.currency.id,
|
|
710
710
|
country=self.underlying_quote.country.id if self.underlying_quote.country else None,
|
|
711
|
-
is_cash=self.underlying_quote.is_cash,
|
|
711
|
+
is_cash=self.underlying_quote.is_cash or self.underlying_quote.is_cash_equivalent,
|
|
712
712
|
primary_classification=(
|
|
713
713
|
self.underlying_quote.primary_classification.id
|
|
714
714
|
if self.underlying_quote.primary_classification
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -926,7 +926,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
926
926
|
if self.is_composition:
|
|
927
927
|
assets = list(self.get_lookthrough_positions(val_date, **kwargs))
|
|
928
928
|
else:
|
|
929
|
-
assets = self.assets.filter(date=val_date)
|
|
929
|
+
assets = list(self.assets.filter(date=val_date))
|
|
930
930
|
return assets
|
|
931
931
|
|
|
932
932
|
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
@@ -2,10 +2,9 @@ import logging
|
|
|
2
2
|
from contextlib import suppress
|
|
3
3
|
from datetime import date, timedelta
|
|
4
4
|
from decimal import Decimal
|
|
5
|
-
from typing import TypeVar
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
6
|
|
|
7
7
|
from celery import shared_task
|
|
8
|
-
from django.contrib.messages import error, info
|
|
9
8
|
from django.core.exceptions import ValidationError
|
|
10
9
|
from django.db import models
|
|
11
10
|
from django.utils.functional import cached_property
|
|
@@ -92,14 +91,14 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
92
91
|
)
|
|
93
92
|
super().save(*args, **kwargs)
|
|
94
93
|
|
|
95
|
-
@property
|
|
96
|
-
def checked_object(self):
|
|
97
|
-
return self.portfolio
|
|
98
|
-
|
|
99
94
|
@property
|
|
100
95
|
def check_evaluation_date(self):
|
|
101
96
|
return self.trade_date
|
|
102
97
|
|
|
98
|
+
@property
|
|
99
|
+
def checked_object(self) -> Any:
|
|
100
|
+
return self.portfolio
|
|
101
|
+
|
|
103
102
|
@cached_property
|
|
104
103
|
def portfolio_total_asset_value(self) -> Decimal:
|
|
105
104
|
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
@@ -109,10 +108,13 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
109
108
|
"""
|
|
110
109
|
This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
|
|
111
110
|
"""
|
|
111
|
+
target_portfolio = self._build_dto().convert_to_portfolio()
|
|
112
|
+
|
|
112
113
|
return TradingService(
|
|
113
114
|
self.trade_date,
|
|
114
115
|
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
115
|
-
target_portfolio=
|
|
116
|
+
target_portfolio=target_portfolio,
|
|
117
|
+
total_target_weight=target_portfolio.total_weight,
|
|
116
118
|
)
|
|
117
119
|
|
|
118
120
|
@cached_property
|
|
@@ -197,25 +199,30 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
197
199
|
|
|
198
200
|
return trade_proposal_clone
|
|
199
201
|
|
|
200
|
-
def normalize_trades(self):
|
|
202
|
+
def normalize_trades(self, total_target_weight: Decimal = Decimal("1.0")):
|
|
201
203
|
"""
|
|
202
204
|
Call the trading service with the existing trades and normalize them in order to obtain a total sum target weight of 100%
|
|
203
205
|
The existing trade will be modified directly with the given normalization factor
|
|
204
206
|
"""
|
|
205
|
-
service = TradingService(
|
|
206
|
-
|
|
207
|
+
service = TradingService(
|
|
208
|
+
self.trade_date,
|
|
209
|
+
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
210
|
+
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
211
|
+
total_target_weight=total_target_weight,
|
|
212
|
+
)
|
|
207
213
|
leftovers_trades = self.trades.all()
|
|
208
|
-
total_target_weight = Decimal("0.0")
|
|
209
214
|
for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
|
|
210
215
|
with suppress(Trade.DoesNotExist):
|
|
211
216
|
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
212
217
|
trade.weighting = round(trade_dto.delta_weight, 6)
|
|
213
218
|
trade.save()
|
|
214
|
-
total_target_weight += trade._target_weight
|
|
215
219
|
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
216
220
|
leftovers_trades.delete()
|
|
221
|
+
t_weight = self.trades.all().annotate_base_info().aggregate(models.Sum("target_weight"))[
|
|
222
|
+
"target_weight__sum"
|
|
223
|
+
] or Decimal("0.0")
|
|
217
224
|
# we handle quantization error due to the decimal max digits. In that case, we take the biggest trade (highest weight) and we remove the quantization error
|
|
218
|
-
if quantize_error := (
|
|
225
|
+
if quantize_error := (t_weight - total_target_weight):
|
|
219
226
|
biggest_trade = self.trades.latest("weighting")
|
|
220
227
|
biggest_trade.weighting -= quantize_error
|
|
221
228
|
biggest_trade.save()
|
|
@@ -234,7 +241,12 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
234
241
|
# Return the current portfolio by default
|
|
235
242
|
return self.portfolio._build_dto(self.last_effective_date)
|
|
236
243
|
|
|
237
|
-
def reset_trades(
|
|
244
|
+
def reset_trades(
|
|
245
|
+
self,
|
|
246
|
+
target_portfolio: PortfolioDTO | None = None,
|
|
247
|
+
validate_trade: bool = True,
|
|
248
|
+
total_target_weight: Decimal = Decimal("1.0"),
|
|
249
|
+
):
|
|
238
250
|
"""
|
|
239
251
|
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
240
252
|
"""
|
|
@@ -249,6 +261,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
249
261
|
self.trade_date,
|
|
250
262
|
effective_portfolio=effective_portfolio,
|
|
251
263
|
target_portfolio=target_portfolio,
|
|
264
|
+
total_target_weight=total_target_weight,
|
|
252
265
|
)
|
|
253
266
|
if validate_trade:
|
|
254
267
|
service.is_valid()
|
|
@@ -279,6 +292,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
279
292
|
status=Trade.Status.DRAFT,
|
|
280
293
|
currency_fx_rate=currency_fx_rate,
|
|
281
294
|
)
|
|
295
|
+
trade.price = trade.get_price()
|
|
296
|
+
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
297
|
+
if trade.price is None:
|
|
298
|
+
trade.price = Decimal("0.0")
|
|
299
|
+
trade.weighting = -trade_dto.effective_weight
|
|
282
300
|
trade.save()
|
|
283
301
|
|
|
284
302
|
def replay(self):
|
|
@@ -329,7 +347,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
329
347
|
last_trade_proposal_created = False
|
|
330
348
|
last_trade_proposal = next_trade_proposal
|
|
331
349
|
|
|
332
|
-
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal:
|
|
350
|
+
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal) -> Decimal:
|
|
333
351
|
"""
|
|
334
352
|
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
335
353
|
|
|
@@ -342,23 +360,19 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
342
360
|
Returns:
|
|
343
361
|
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
344
362
|
"""
|
|
345
|
-
|
|
346
|
-
# Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
347
|
-
quote_price = Decimal(underlying_quote.get_price(self.trade_date))
|
|
363
|
+
# Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
348
364
|
|
|
349
|
-
|
|
350
|
-
|
|
365
|
+
# Calculate the trade's total value in the portfolio's currency
|
|
366
|
+
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
351
367
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
368
|
+
# Convert the quote price to the portfolio's currency
|
|
369
|
+
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
370
|
+
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
371
|
+
)
|
|
356
372
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
except Exception:
|
|
361
|
-
raise ValueError("We couldn't estimate the number of shares")
|
|
373
|
+
# If the price is valid, calculate and return the estimated shares
|
|
374
|
+
if price_fx_portfolio:
|
|
375
|
+
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
362
376
|
|
|
363
377
|
def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
|
|
364
378
|
"""
|
|
@@ -399,7 +413,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
399
413
|
|
|
400
414
|
# Estimate the target shares for the cash component
|
|
401
415
|
with suppress(ValueError):
|
|
402
|
-
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
|
|
416
|
+
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component, Decimal("1.0"))
|
|
403
417
|
|
|
404
418
|
cash_component = Cash.objects.get_or_create(
|
|
405
419
|
currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
|
|
@@ -447,46 +461,23 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
447
461
|
},
|
|
448
462
|
)
|
|
449
463
|
def submit(self, by=None, description=None, **kwargs):
|
|
450
|
-
self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
|
|
451
464
|
trades = []
|
|
452
|
-
|
|
465
|
+
trades_validation_warnings = []
|
|
453
466
|
for trade in self.trades.all():
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
f"Trade failed because no price is found for {trade.underlying_instrument.computed_str} on {trade.transaction_date:%Y-%m-%d}"
|
|
458
|
-
)
|
|
459
|
-
else:
|
|
460
|
-
trade.status = Trade.Status.SUBMIT
|
|
467
|
+
trade_warnings = trade.submit(by=by, description=description, **kwargs)
|
|
468
|
+
if trade_warnings:
|
|
469
|
+
trades_validation_warnings.extend(trade_warnings)
|
|
461
470
|
trades.append(trade)
|
|
462
471
|
|
|
463
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
472
|
+
Trade.objects.bulk_update(trades, ["status", "shares", "weighting"])
|
|
464
473
|
|
|
465
474
|
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
466
475
|
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
467
|
-
target_portfolio = self.validated_trading_service.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
estimated_cash_position.underlying_quote.id
|
|
471
|
-
):
|
|
472
|
-
existing_cash_position += estimated_cash_position
|
|
473
|
-
else:
|
|
474
|
-
target_portfolio.positions_map[estimated_cash_position.underlying_quote.id] = (
|
|
475
|
-
estimated_cash_position._build_dto()
|
|
476
|
-
)
|
|
477
|
-
target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
|
|
476
|
+
target_portfolio = self.validated_trading_service.trades_batch.convert_to_portfolio(
|
|
477
|
+
estimated_cash_position._build_dto()
|
|
478
|
+
)
|
|
478
479
|
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
479
|
-
|
|
480
|
-
if (
|
|
481
|
-
trades_validation_errors
|
|
482
|
-
and (fsm_context := getattr(self, "fsm_context", None))
|
|
483
|
-
and (request := fsm_context.get("request", None))
|
|
484
|
-
):
|
|
485
|
-
msg = f"""The reason for the failed trades are: <ul>
|
|
486
|
-
{''.join(map(lambda o: '<li>' + o + '</li>', trades_validation_errors))}
|
|
487
|
-
</ul>
|
|
488
|
-
"""
|
|
489
|
-
error(request, msg, extra_tags="auto_close=0")
|
|
480
|
+
return trades_validation_warnings
|
|
490
481
|
|
|
491
482
|
def can_submit(self):
|
|
492
483
|
errors = dict()
|
|
@@ -496,7 +487,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
496
487
|
service = self.validated_trading_service
|
|
497
488
|
try:
|
|
498
489
|
service.is_valid(ignore_error=True)
|
|
499
|
-
# if service.trades_batch.
|
|
490
|
+
# if service.trades_batch.total_abs_delta_weight == 0:
|
|
500
491
|
# errors_list.append(
|
|
501
492
|
# "There is no change detected in this trade proposal. Please submit at last one valid trade"
|
|
502
493
|
# )
|
|
@@ -542,6 +533,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
542
533
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
543
534
|
trades = []
|
|
544
535
|
assets = []
|
|
536
|
+
warnings = []
|
|
545
537
|
# We do not want to create the estimated cash position if there is not trades in the trade proposal (shouldn't be possible anyway)
|
|
546
538
|
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
547
539
|
|
|
@@ -556,13 +548,11 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
556
548
|
|
|
557
549
|
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
558
550
|
if estimated_cash_position.weighting and len(trades) > 0:
|
|
551
|
+
warnings.append(
|
|
552
|
+
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
553
|
+
)
|
|
559
554
|
estimated_cash_position.pre_save()
|
|
560
555
|
assets.append(estimated_cash_position)
|
|
561
|
-
if (fsm_context := getattr(self, "fsm_context", None)) and (request := fsm_context.get("request", None)):
|
|
562
|
-
info(
|
|
563
|
-
request,
|
|
564
|
-
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}",
|
|
565
|
-
)
|
|
566
556
|
|
|
567
557
|
Trade.objects.bulk_update(trades, ["status"])
|
|
568
558
|
self.portfolio.bulk_create_positions(
|
|
@@ -570,6 +560,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
570
560
|
)
|
|
571
561
|
if replay and self.portfolio.is_manageable:
|
|
572
562
|
replay_as_task.delay(self.id, user_id=by.id if by else None)
|
|
563
|
+
return warnings
|
|
573
564
|
|
|
574
565
|
def can_approve(self):
|
|
575
566
|
errors = dict()
|
|
@@ -249,9 +249,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
249
249
|
field=status,
|
|
250
250
|
source=Status.DRAFT,
|
|
251
251
|
target=GET_STATE(
|
|
252
|
-
lambda self, **kwargs: (
|
|
253
|
-
self.Status.SUBMIT if self.last_underlying_quote_price is not None else self.Status.FAILED
|
|
254
|
-
),
|
|
252
|
+
lambda self, **kwargs: (self.Status.SUBMIT if self.price else self.Status.FAILED),
|
|
255
253
|
states=[Status.SUBMIT, Status.FAILED],
|
|
256
254
|
),
|
|
257
255
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
@@ -271,7 +269,12 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
271
269
|
on_error="FAILED",
|
|
272
270
|
)
|
|
273
271
|
def submit(self, by=None, description=None, **kwargs):
|
|
274
|
-
|
|
272
|
+
errors = []
|
|
273
|
+
if not self.price:
|
|
274
|
+
errors.append(
|
|
275
|
+
f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
276
|
+
)
|
|
277
|
+
return errors
|
|
275
278
|
|
|
276
279
|
def can_submit(self):
|
|
277
280
|
pass
|
|
@@ -287,6 +290,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
287
290
|
def fail(self, **kwargs):
|
|
288
291
|
pass
|
|
289
292
|
|
|
293
|
+
# TODO To be removed
|
|
290
294
|
@cached_property
|
|
291
295
|
def last_underlying_quote_price(self) -> InstrumentPrice | None:
|
|
292
296
|
try:
|
|
@@ -343,7 +347,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
343
347
|
)
|
|
344
348
|
|
|
345
349
|
def can_execute(self):
|
|
346
|
-
if not self.
|
|
350
|
+
if not self.price:
|
|
347
351
|
return {"underlying_instrument": [_("Cannot execute a trade without a valid quote price")]}
|
|
348
352
|
if not self.portfolio.is_manageable:
|
|
349
353
|
return {
|
|
@@ -531,19 +535,22 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
531
535
|
|
|
532
536
|
def save(self, *args, **kwargs):
|
|
533
537
|
if self.trade_proposal:
|
|
538
|
+
if not self.underlying_instrument.is_investable_universe:
|
|
539
|
+
self.underlying_instrument.is_investable_universe = True
|
|
540
|
+
self.underlying_instrument.save()
|
|
534
541
|
self.portfolio = self.trade_proposal.portfolio
|
|
535
542
|
self.transaction_date = self.trade_proposal.trade_date
|
|
536
543
|
self.value_date = self.trade_proposal.last_effective_date
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
544
|
+
if self.price is None:
|
|
545
|
+
# we try to get the price if not provided directly from the underlying instrument
|
|
546
|
+
self.price = self.get_price()
|
|
547
|
+
if self.trade_proposal and not self.portfolio.only_weighting:
|
|
548
|
+
self.shares = self.trade_proposal.get_estimated_shares(
|
|
549
|
+
self.weighting, self.underlying_instrument, self.price
|
|
550
|
+
)
|
|
540
551
|
|
|
541
552
|
if not self.custodian and self.bank:
|
|
542
553
|
self.custodian = Custodian.get_by_mapping(self.bank)
|
|
543
|
-
if self.price is None:
|
|
544
|
-
# we try to get the price if not provided directly from the underlying instrument
|
|
545
|
-
with suppress(Exception):
|
|
546
|
-
self.price = self.underlying_instrument.get_price(self.value_date)
|
|
547
554
|
|
|
548
555
|
if self.transaction_subtype is None or self.trade_proposal:
|
|
549
556
|
# if subtype not provided, we extract it automatically from the existing data.
|
|
@@ -588,9 +595,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
588
595
|
"""
|
|
589
596
|
|
|
590
597
|
def get_asset(self) -> AssetPosition:
|
|
591
|
-
last_underlying_quote_price = self.last_underlying_quote_price
|
|
592
|
-
if not last_underlying_quote_price:
|
|
593
|
-
raise ValueError("No price found")
|
|
594
598
|
asset = AssetPosition(
|
|
595
599
|
underlying_quote=self.underlying_instrument,
|
|
596
600
|
portfolio_created=None,
|
|
@@ -598,9 +602,8 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
598
602
|
date=self.transaction_date,
|
|
599
603
|
initial_currency_fx_rate=self.currency_fx_rate,
|
|
600
604
|
weighting=self._target_weight,
|
|
601
|
-
initial_price=self.
|
|
605
|
+
initial_price=self.price,
|
|
602
606
|
initial_shares=None,
|
|
603
|
-
underlying_quote_price=self.last_underlying_quote_price,
|
|
604
607
|
asset_valuation_date=self.transaction_date,
|
|
605
608
|
currency=self.currency,
|
|
606
609
|
is_estimated=False,
|
|
@@ -609,6 +612,11 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
609
612
|
asset.pre_save()
|
|
610
613
|
return asset
|
|
611
614
|
|
|
615
|
+
def get_price(self) -> Decimal | None:
|
|
616
|
+
if self.value_date:
|
|
617
|
+
with suppress(ValueError):
|
|
618
|
+
return Decimal(self.underlying_instrument.get_price(self.value_date))
|
|
619
|
+
|
|
612
620
|
def delete(self, **kwargs):
|
|
613
621
|
pre_collection.send(sender=self.__class__, instance=self)
|
|
614
622
|
super().delete(**kwargs)
|
|
@@ -631,6 +639,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
631
639
|
instrument_type=self.underlying_instrument.security_instrument_type.id,
|
|
632
640
|
currency=self.underlying_instrument.currency,
|
|
633
641
|
date=self.transaction_date,
|
|
642
|
+
is_cash=self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent,
|
|
634
643
|
)
|
|
635
644
|
|
|
636
645
|
def get_alternative_valid_trades(self, share_delta: float = 0):
|
|
@@ -17,25 +17,20 @@ class TradingService:
|
|
|
17
17
|
trade_date: date,
|
|
18
18
|
effective_portfolio: Portfolio | None = None,
|
|
19
19
|
target_portfolio: Portfolio | None = None,
|
|
20
|
-
|
|
21
|
-
total_value: Decimal = None,
|
|
20
|
+
total_target_weight: Decimal = Decimal("1.0"),
|
|
22
21
|
):
|
|
23
|
-
self.total_value = total_value
|
|
24
22
|
self.trade_date = trade_date
|
|
25
23
|
if target_portfolio is None:
|
|
26
24
|
target_portfolio = Portfolio(positions=())
|
|
27
25
|
if effective_portfolio is None:
|
|
28
26
|
effective_portfolio = Portfolio(positions=())
|
|
29
|
-
# If effective
|
|
30
|
-
trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if trades_batch and not target_portfolio:
|
|
34
|
-
target_portfolio = trades_batch.convert_to_portfolio()
|
|
27
|
+
# If effective portfolio and trades batch is provided, we ensure the trade batch contains at least one trade for every position
|
|
28
|
+
self.trades_batch = self.build_trade_batch(effective_portfolio, target_portfolio).normalize(
|
|
29
|
+
total_target_weight
|
|
30
|
+
)
|
|
35
31
|
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
self.target_portfolio = target_portfolio
|
|
32
|
+
self._effective_portfolio = effective_portfolio
|
|
33
|
+
self._target_portfolio = target_portfolio
|
|
39
34
|
|
|
40
35
|
@property
|
|
41
36
|
def errors(self) -> list[str]:
|
|
@@ -62,12 +57,11 @@ class TradingService:
|
|
|
62
57
|
Test the given value against all the validators on the field,
|
|
63
58
|
and either raise a `ValidationError` or simply return.
|
|
64
59
|
"""
|
|
65
|
-
|
|
66
|
-
if self.effective_portfolio:
|
|
60
|
+
if self._effective_portfolio:
|
|
67
61
|
for trade in validated_trades:
|
|
68
62
|
if (
|
|
69
63
|
trade.effective_weight
|
|
70
|
-
and trade.underlying_instrument not in self.
|
|
64
|
+
and trade.underlying_instrument not in self._effective_portfolio.positions_map
|
|
71
65
|
):
|
|
72
66
|
raise ValidationError("All effective position needs to be matched with a validated trade")
|
|
73
67
|
|
|
@@ -75,7 +69,6 @@ class TradingService:
|
|
|
75
69
|
self,
|
|
76
70
|
effective_portfolio: Portfolio,
|
|
77
71
|
target_portfolio: Portfolio,
|
|
78
|
-
trades_batch: TradeBatch | None = None,
|
|
79
72
|
) -> TradeBatch:
|
|
80
73
|
"""
|
|
81
74
|
Given combination of effective portfolio and either a trades batch or a target portfolio, ensure all theres variables are set
|
|
@@ -87,39 +80,35 @@ class TradingService:
|
|
|
87
80
|
|
|
88
81
|
Returns: The normalized trades batch
|
|
89
82
|
"""
|
|
90
|
-
instruments =
|
|
91
|
-
instruments.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
date=self.trade_date,
|
|
118
|
-
instrument_type=instrument_type,
|
|
119
|
-
currency=currency,
|
|
83
|
+
instruments = effective_portfolio.positions_map.copy()
|
|
84
|
+
instruments.update(target_portfolio.positions_map)
|
|
85
|
+
|
|
86
|
+
trades: list[Trade] = []
|
|
87
|
+
for instrument_id, pos in instruments.items():
|
|
88
|
+
if not pos.is_cash:
|
|
89
|
+
effective_weight = target_weight = 0
|
|
90
|
+
effective_shares = 0
|
|
91
|
+
instrument_type = currency = None
|
|
92
|
+
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
93
|
+
effective_weight = effective_pos.weighting
|
|
94
|
+
effective_shares = effective_pos.shares
|
|
95
|
+
instrument_type, currency = effective_pos.instrument_type, effective_pos.currency
|
|
96
|
+
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
97
|
+
target_weight = target_pos.weighting
|
|
98
|
+
instrument_type, currency = target_pos.instrument_type, target_pos.currency
|
|
99
|
+
|
|
100
|
+
trades.append(
|
|
101
|
+
Trade(
|
|
102
|
+
underlying_instrument=instrument_id,
|
|
103
|
+
effective_weight=effective_weight,
|
|
104
|
+
target_weight=target_weight,
|
|
105
|
+
effective_shares=effective_shares,
|
|
106
|
+
date=self.trade_date,
|
|
107
|
+
instrument_type=instrument_type,
|
|
108
|
+
currency=currency,
|
|
109
|
+
)
|
|
120
110
|
)
|
|
121
|
-
|
|
122
|
-
return TradeBatch(tuple(_trades))
|
|
111
|
+
return TradeBatch(tuple(trades))
|
|
123
112
|
|
|
124
113
|
def is_valid(self, ignore_error: bool = False) -> bool:
|
|
125
114
|
"""
|
|
@@ -151,11 +140,3 @@ class TradingService:
|
|
|
151
140
|
raise ValidationError(self.errors)
|
|
152
141
|
|
|
153
142
|
return not bool(self._errors)
|
|
154
|
-
|
|
155
|
-
def normalize(self):
|
|
156
|
-
"""
|
|
157
|
-
Normalize the instantiate trades batch so that the target weight is 100%
|
|
158
|
-
"""
|
|
159
|
-
self.trades_batch = TradeBatch(
|
|
160
|
-
[trade.normalize_target(self.trades_batch.total_target_weight) for trade in self.trades_batch.trades]
|
|
161
|
-
)
|
wbportfolio/pms/typing.py
CHANGED
|
@@ -48,7 +48,7 @@ class Position:
|
|
|
48
48
|
@dataclass(frozen=True)
|
|
49
49
|
class Portfolio:
|
|
50
50
|
positions: tuple[Position] | tuple
|
|
51
|
-
positions_map: dict[Position] = field(init=False, repr=False)
|
|
51
|
+
positions_map: dict[int, Position] = field(init=False, repr=False)
|
|
52
52
|
|
|
53
53
|
def __post_init__(self):
|
|
54
54
|
positions_map = {}
|
|
@@ -80,7 +80,7 @@ class Portfolio:
|
|
|
80
80
|
@dataclass(frozen=True)
|
|
81
81
|
class Trade:
|
|
82
82
|
underlying_instrument: int
|
|
83
|
-
instrument_type:
|
|
83
|
+
instrument_type: int
|
|
84
84
|
currency: int
|
|
85
85
|
date: date_lib
|
|
86
86
|
|
|
@@ -88,6 +88,7 @@ class Trade:
|
|
|
88
88
|
target_weight: Decimal
|
|
89
89
|
id: int | None = None
|
|
90
90
|
effective_shares: Decimal = None
|
|
91
|
+
is_cash: bool = False
|
|
91
92
|
|
|
92
93
|
def __add__(self, other):
|
|
93
94
|
return Trade(
|
|
@@ -121,9 +122,9 @@ class Trade:
|
|
|
121
122
|
# if self.target_weight < 0 or self.target_weight > 1.0:
|
|
122
123
|
# raise ValidationError("Target Weight needs to be in range [0, 1]")
|
|
123
124
|
|
|
124
|
-
def normalize_target(self,
|
|
125
|
+
def normalize_target(self, factor: Decimal):
|
|
125
126
|
t = Trade(
|
|
126
|
-
target_weight=self.target_weight
|
|
127
|
+
target_weight=self.target_weight * factor,
|
|
127
128
|
**{f.name: getattr(self, f.name) for f in fields(Trade) if f.name not in ["target_weight"]},
|
|
128
129
|
)
|
|
129
130
|
return t
|
|
@@ -145,15 +146,15 @@ class TradeBatch:
|
|
|
145
146
|
|
|
146
147
|
@property
|
|
147
148
|
def total_target_weight(self) -> Decimal:
|
|
148
|
-
return round(sum([trade.target_weight for trade in self.trades]), 6)
|
|
149
|
+
return round(sum([trade.target_weight for trade in self.trades], Decimal("0")), 6)
|
|
149
150
|
|
|
150
151
|
@property
|
|
151
152
|
def total_effective_weight(self) -> Decimal:
|
|
152
|
-
return round(sum([trade.effective_weight for trade in self.trades]), 6)
|
|
153
|
+
return round(sum([trade.effective_weight for trade in self.trades], Decimal("0")), 6)
|
|
153
154
|
|
|
154
155
|
@property
|
|
155
|
-
def
|
|
156
|
-
return sum([abs(trade.delta_weight) for trade in self.trades])
|
|
156
|
+
def total_abs_delta_weight(self) -> Decimal:
|
|
157
|
+
return sum([abs(trade.delta_weight) for trade in self.trades], Decimal("0"))
|
|
157
158
|
|
|
158
159
|
def __add__(self, other):
|
|
159
160
|
return TradeBatch(tuple(self.trades + other.trades))
|
|
@@ -165,7 +166,7 @@ class TradeBatch:
|
|
|
165
166
|
if round(float(self.total_target_weight), 4) != 1: # we do that to remove decimal over precision
|
|
166
167
|
raise ValidationError(f"Total Weight cannot be different than 1 ({float(self.total_target_weight)})")
|
|
167
168
|
|
|
168
|
-
def convert_to_portfolio(self):
|
|
169
|
+
def convert_to_portfolio(self, *extra_positions):
|
|
169
170
|
positions = []
|
|
170
171
|
for instrument, trade in self.trades_map.items():
|
|
171
172
|
positions.append(
|
|
@@ -175,6 +176,23 @@ class TradeBatch:
|
|
|
175
176
|
weighting=trade.target_weight,
|
|
176
177
|
currency=trade.currency,
|
|
177
178
|
date=trade.date,
|
|
179
|
+
is_cash=trade.is_cash,
|
|
178
180
|
)
|
|
179
181
|
)
|
|
182
|
+
for position in extra_positions:
|
|
183
|
+
if position.weighting:
|
|
184
|
+
positions.append(position)
|
|
180
185
|
return Portfolio(tuple(positions))
|
|
186
|
+
|
|
187
|
+
def normalize(self, total_target_weight: Decimal = Decimal("1.0")):
|
|
188
|
+
"""
|
|
189
|
+
Normalize the instantiate trades batch so that the target weight is 100%
|
|
190
|
+
"""
|
|
191
|
+
normalization_factor = (
|
|
192
|
+
total_target_weight / self.total_target_weight if self.total_target_weight else Decimal("0.0")
|
|
193
|
+
)
|
|
194
|
+
normalized_trades = []
|
|
195
|
+
for trade in self.trades:
|
|
196
|
+
normalized_trades.append(trade.normalize_target(normalization_factor))
|
|
197
|
+
tb = TradeBatch(normalized_trades)
|
|
198
|
+
return tb
|
|
@@ -16,25 +16,22 @@ class ModelPortfolioRebalancing(AbstractRebalancingModel):
|
|
|
16
16
|
|
|
17
17
|
@property
|
|
18
18
|
def model_portfolio(self):
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
return self.model_portfolio_rel.dependency_portfolio if self.model_portfolio_rel else None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def assets(self):
|
|
23
|
+
return self.model_portfolio.get_positions(self.last_effective_date) if self.model_portfolio else []
|
|
21
24
|
|
|
22
25
|
def is_valid(self) -> bool:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
date=self.trade_date, instrument__in=assets.values("underlying_quote")
|
|
29
|
-
).exists()
|
|
30
|
-
)
|
|
31
|
-
return False
|
|
26
|
+
instruments = list(map(lambda o: o.underlying_quote, self.assets))
|
|
27
|
+
return (
|
|
28
|
+
len(self.assets) > 0
|
|
29
|
+
and InstrumentPrice.objects.filter(date=self.trade_date, instrument__in=instruments).exists()
|
|
30
|
+
)
|
|
32
31
|
|
|
33
32
|
def get_target_portfolio(self) -> Portfolio:
|
|
34
33
|
positions = []
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for asset in assets:
|
|
34
|
+
for asset in self.assets:
|
|
38
35
|
asset.date = self.trade_date
|
|
39
36
|
asset.asset_valuation_date = self.trade_date
|
|
40
37
|
positions.append(asset._build_dto())
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
1
3
|
from django.contrib.messages import warning
|
|
2
4
|
from django.core.exceptions import ValidationError
|
|
3
5
|
from rest_framework.reverse import reverse
|
|
@@ -16,6 +18,17 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
16
18
|
queryset=Portfolio.objects.all(), write_only=True, required=False, default=DefaultFromView("portfolio")
|
|
17
19
|
)
|
|
18
20
|
_target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
|
|
21
|
+
total_cash_weight = wb_serializers.DecimalField(
|
|
22
|
+
default=0,
|
|
23
|
+
decimal_places=4,
|
|
24
|
+
max_digits=5,
|
|
25
|
+
write_only=True,
|
|
26
|
+
required=False,
|
|
27
|
+
precision=4,
|
|
28
|
+
percent=True,
|
|
29
|
+
label="Target Cash",
|
|
30
|
+
help_text="Enter the desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
31
|
+
)
|
|
19
32
|
|
|
20
33
|
trade_date = wb_serializers.DateField(
|
|
21
34
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
@@ -23,6 +36,7 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
23
36
|
|
|
24
37
|
def create(self, validated_data):
|
|
25
38
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
39
|
+
total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
|
|
26
40
|
rebalancing_model = validated_data.get("rebalancing_model", None)
|
|
27
41
|
if request := self.context.get("request"):
|
|
28
42
|
validated_data["creator"] = request.user.profile
|
|
@@ -32,7 +46,9 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
32
46
|
if target_portfolio and not rebalancing_model and (last_effective_date := obj.last_effective_date):
|
|
33
47
|
target_portfolio_dto = target_portfolio._build_dto(last_effective_date)
|
|
34
48
|
try:
|
|
35
|
-
obj.reset_trades(
|
|
49
|
+
obj.reset_trades(
|
|
50
|
+
target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
|
|
51
|
+
)
|
|
36
52
|
except ValidationError as e:
|
|
37
53
|
if request := self.context.get("request"):
|
|
38
54
|
warning(request, str(e), extra_tags="auto_close=0")
|
|
@@ -60,6 +76,7 @@ class TradeProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
60
76
|
fields = (
|
|
61
77
|
"id",
|
|
62
78
|
"trade_date",
|
|
79
|
+
"total_cash_weight",
|
|
63
80
|
"comment",
|
|
64
81
|
"status",
|
|
65
82
|
"portfolio",
|
|
@@ -320,6 +320,16 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
320
320
|
effective_shares = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
321
321
|
target_shares = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
322
322
|
|
|
323
|
+
total_value_fx_portfolio = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=2, default=0)
|
|
324
|
+
effective_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
325
|
+
read_only=True, max_digits=16, decimal_places=2, default=0
|
|
326
|
+
)
|
|
327
|
+
target_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
328
|
+
read_only=True, max_digits=16, decimal_places=2, default=0
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
portfolio_currency = wb_serializers.CharField(read_only=True)
|
|
332
|
+
|
|
323
333
|
def validate(self, data):
|
|
324
334
|
data.pop("company", None)
|
|
325
335
|
data.pop("security", None)
|
|
@@ -331,6 +341,8 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
331
341
|
)
|
|
332
342
|
effective_weight = self.instance._effective_weight if self.instance else Decimal(0.0)
|
|
333
343
|
weighting = data.get("weighting", self.instance.weighting if self.instance else Decimal(0.0))
|
|
344
|
+
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
345
|
+
weighting = target_weight - effective_weight
|
|
334
346
|
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
335
347
|
weighting = target_weight - effective_weight
|
|
336
348
|
if weighting >= 0:
|
|
@@ -343,14 +355,25 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
343
355
|
class Meta:
|
|
344
356
|
model = Trade
|
|
345
357
|
percent_fields = ["effective_weight", "target_weight", "weighting"]
|
|
358
|
+
decorators = {
|
|
359
|
+
"total_value_fx_portfolio": wb_serializers.decorator(
|
|
360
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
361
|
+
),
|
|
362
|
+
"effective_total_value_fx_portfolio": wb_serializers.decorator(
|
|
363
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
364
|
+
),
|
|
365
|
+
"target_total_value_fx_portfolio": wb_serializers.decorator(
|
|
366
|
+
decorator_type="text", position="left", value="{{portfolio_currency}}"
|
|
367
|
+
),
|
|
368
|
+
}
|
|
346
369
|
read_only_fields = (
|
|
347
370
|
"transaction_subtype",
|
|
348
371
|
"shares",
|
|
349
|
-
# "underlying_instrument",
|
|
350
|
-
# "_underlying_instrument",
|
|
351
|
-
"shares",
|
|
352
372
|
"effective_shares",
|
|
353
373
|
"target_shares",
|
|
374
|
+
"total_value_fx_portfolio",
|
|
375
|
+
"effective_total_value_fx_portfolio",
|
|
376
|
+
"target_total_value_fx_portfolio",
|
|
354
377
|
)
|
|
355
378
|
fields = (
|
|
356
379
|
"id",
|
|
@@ -375,6 +398,10 @@ class TradeTradeProposalModelSerializer(TradeModelSerializer):
|
|
|
375
398
|
"order",
|
|
376
399
|
"effective_shares",
|
|
377
400
|
"target_shares",
|
|
401
|
+
"total_value_fx_portfolio",
|
|
402
|
+
"effective_total_value_fx_portfolio",
|
|
403
|
+
"target_total_value_fx_portfolio",
|
|
404
|
+
"portfolio_currency",
|
|
378
405
|
)
|
|
379
406
|
|
|
380
407
|
|
|
@@ -66,11 +66,11 @@ class TestTradeProposal:
|
|
|
66
66
|
validated_trading_service = trade_proposal.validated_trading_service
|
|
67
67
|
|
|
68
68
|
# Assert effective and target portfolios are as expected
|
|
69
|
-
assert validated_trading_service.
|
|
69
|
+
assert validated_trading_service._effective_portfolio.to_dict() == {
|
|
70
70
|
a1.underlying_quote.id: a1.weighting,
|
|
71
71
|
a2.underlying_quote.id: a2.weighting,
|
|
72
72
|
}
|
|
73
|
-
assert validated_trading_service.
|
|
73
|
+
assert validated_trading_service._target_portfolio.to_dict() == {
|
|
74
74
|
a1.underlying_quote.id: a1.weighting + t1.weighting,
|
|
75
75
|
a2.underlying_quote.id: a2.weighting + t2.weighting,
|
|
76
76
|
}
|
|
@@ -361,6 +361,8 @@ class TestTradeProposal:
|
|
|
361
361
|
"""
|
|
362
362
|
portfolio = trade_proposal.portfolio
|
|
363
363
|
instrument = instrument_factory.create(currency=portfolio.currency)
|
|
364
|
+
underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=trade_proposal.trade_date)
|
|
365
|
+
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
364
366
|
trade = trade_factory.create(
|
|
365
367
|
trade_proposal=trade_proposal,
|
|
366
368
|
transaction_date=trade_proposal.trade_date,
|
|
@@ -368,12 +370,12 @@ class TestTradeProposal:
|
|
|
368
370
|
underlying_instrument=instrument,
|
|
369
371
|
)
|
|
370
372
|
trade.refresh_from_db()
|
|
371
|
-
underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=trade.transaction_date)
|
|
372
|
-
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
373
373
|
|
|
374
374
|
# Assert estimated shares are correctly calculated
|
|
375
375
|
assert (
|
|
376
|
-
trade_proposal.get_estimated_shares(
|
|
376
|
+
trade_proposal.get_estimated_shares(
|
|
377
|
+
trade.weighting, trade.underlying_instrument, underlying_quote_price.net_value
|
|
378
|
+
)
|
|
377
379
|
== Decimal(1_000_000) * trade.weighting / underlying_quote_price.net_value
|
|
378
380
|
)
|
|
379
381
|
|
|
@@ -38,30 +38,24 @@ class TestModelPortfolioRebalancing:
|
|
|
38
38
|
def model(self, portfolio, weekday):
|
|
39
39
|
from wbportfolio.rebalancing.models import ModelPortfolioRebalancing
|
|
40
40
|
|
|
41
|
+
PortfolioPortfolioThroughModel.objects.create(
|
|
42
|
+
portfolio=portfolio,
|
|
43
|
+
dependency_portfolio=PortfolioFactory.create(),
|
|
44
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
45
|
+
)
|
|
41
46
|
return ModelPortfolioRebalancing(portfolio, (weekday + BDay(1)).date(), weekday)
|
|
42
47
|
|
|
43
48
|
def test_is_valid(self, portfolio, weekday, model, asset_position_factory, instrument_price_factory):
|
|
44
49
|
assert not model.is_valid()
|
|
45
50
|
asset_position_factory.create(portfolio=model.portfolio, date=model.last_effective_date)
|
|
46
51
|
assert not model.is_valid()
|
|
47
|
-
|
|
48
|
-
PortfolioPortfolioThroughModel.objects.create(
|
|
49
|
-
portfolio=model.portfolio,
|
|
50
|
-
dependency_portfolio=model_portfolio,
|
|
51
|
-
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
52
|
-
)
|
|
52
|
+
|
|
53
53
|
a = asset_position_factory.create(portfolio=model.model_portfolio, date=model.last_effective_date)
|
|
54
54
|
assert not model.is_valid()
|
|
55
55
|
instrument_price_factory.create(instrument=a.underlying_quote, date=model.trade_date)
|
|
56
56
|
assert model.is_valid()
|
|
57
57
|
|
|
58
58
|
def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
|
|
59
|
-
model_portfolio = PortfolioFactory.create()
|
|
60
|
-
PortfolioPortfolioThroughModel.objects.create(
|
|
61
|
-
portfolio=model.portfolio,
|
|
62
|
-
dependency_portfolio=model_portfolio,
|
|
63
|
-
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
64
|
-
)
|
|
65
59
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
66
60
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
67
61
|
a1 = asset_position_factory(weighting=0.8, portfolio=portfolio.model_portfolio, date=model.last_effective_date)
|
|
@@ -91,7 +85,7 @@ class TestCompositeRebalancing:
|
|
|
91
85
|
transaction_date=model.last_effective_date,
|
|
92
86
|
transaction_subtype=Trade.Type.BUY,
|
|
93
87
|
trade_proposal=trade_proposal,
|
|
94
|
-
weighting=0.7,
|
|
88
|
+
weighting=Decimal(0.7),
|
|
95
89
|
status=Trade.Status.EXECUTED,
|
|
96
90
|
)
|
|
97
91
|
TradeFactory.create(
|
|
@@ -99,7 +93,7 @@ class TestCompositeRebalancing:
|
|
|
99
93
|
transaction_date=model.last_effective_date,
|
|
100
94
|
transaction_subtype=Trade.Type.BUY,
|
|
101
95
|
trade_proposal=trade_proposal,
|
|
102
|
-
weighting=0.3,
|
|
96
|
+
weighting=Decimal(0.3),
|
|
103
97
|
status=Trade.Status.EXECUTED,
|
|
104
98
|
)
|
|
105
99
|
assert not model.is_valid()
|
|
@@ -117,7 +111,7 @@ class TestCompositeRebalancing:
|
|
|
117
111
|
transaction_date=model.last_effective_date,
|
|
118
112
|
transaction_subtype=Trade.Type.BUY,
|
|
119
113
|
trade_proposal=trade_proposal,
|
|
120
|
-
weighting=0.8,
|
|
114
|
+
weighting=Decimal(0.8),
|
|
121
115
|
status=Trade.Status.EXECUTED,
|
|
122
116
|
)
|
|
123
117
|
t2 = TradeFactory.create(
|
|
@@ -125,7 +119,7 @@ class TestCompositeRebalancing:
|
|
|
125
119
|
transaction_date=model.last_effective_date,
|
|
126
120
|
transaction_subtype=Trade.Type.BUY,
|
|
127
121
|
trade_proposal=trade_proposal,
|
|
128
|
-
weighting=0.2,
|
|
122
|
+
weighting=Decimal(0.2),
|
|
129
123
|
status=Trade.Status.EXECUTED,
|
|
130
124
|
)
|
|
131
125
|
target_portfolio = model.get_target_portfolio()
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
from wbcore import serializers as wb_serializers
|
|
1
2
|
from wbcore.contrib.icons import WBIcon
|
|
2
3
|
from wbcore.enums import RequestType
|
|
3
4
|
from wbcore.metadata.configs import buttons as bt
|
|
4
5
|
from wbcore.metadata.configs.buttons.view_config import ButtonViewConfig
|
|
6
|
+
from wbcore.metadata.configs.display import create_simple_display
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NormalizeSerializer(wb_serializers.Serializer):
|
|
10
|
+
total_cash_weight = wb_serializers.FloatField(default=0, precision=4, percent=True)
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
class TradeProposalButtonConfig(ButtonViewConfig):
|
|
@@ -41,10 +47,12 @@ class TradeProposalButtonConfig(ButtonViewConfig):
|
|
|
41
47
|
icon=WBIcon.EDIT.icon,
|
|
42
48
|
label="Normalize Trades",
|
|
43
49
|
description_fields="""
|
|
44
|
-
<p>Make sure all trades normalize to a total target weight of 100%</p>
|
|
50
|
+
<p>Make sure all trades normalize to a total target weight of (100 - {{total_cash_weight}})%</p>
|
|
45
51
|
""",
|
|
46
52
|
action_label="Normalize Trades",
|
|
47
53
|
title="Normalize Trades",
|
|
54
|
+
serializer=NormalizeSerializer,
|
|
55
|
+
instance_display=create_simple_display([["total_cash_weight"]]),
|
|
48
56
|
),
|
|
49
57
|
bt.ActionButton(
|
|
50
58
|
method=RequestType.PATCH,
|
|
@@ -77,11 +77,12 @@ class TradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
77
77
|
layouts={
|
|
78
78
|
default(): Layout(
|
|
79
79
|
grid_template_areas=[
|
|
80
|
-
["status", "status"
|
|
81
|
-
["trade_date", "
|
|
80
|
+
["status", "status"],
|
|
81
|
+
["trade_date", "total_cash_weight"],
|
|
82
|
+
["rebalancing_model", "target_portfolio"]
|
|
82
83
|
if self.view.new_mode
|
|
83
|
-
else ["
|
|
84
|
-
["comment", "comment"
|
|
84
|
+
else ["rebalancing_model", "rebalancing_model"],
|
|
85
|
+
["comment", "comment"],
|
|
85
86
|
],
|
|
86
87
|
),
|
|
87
88
|
},
|
|
@@ -357,6 +357,42 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
357
357
|
],
|
|
358
358
|
)
|
|
359
359
|
)
|
|
360
|
+
fields.append(
|
|
361
|
+
dp.Field(
|
|
362
|
+
label="Total Value",
|
|
363
|
+
open_by_default=False,
|
|
364
|
+
key=None,
|
|
365
|
+
children=[
|
|
366
|
+
dp.Field(
|
|
367
|
+
key="effective_total_value_fx_portfolio",
|
|
368
|
+
label="Effective Total Value",
|
|
369
|
+
show="open",
|
|
370
|
+
width=Unit.PIXEL(150),
|
|
371
|
+
),
|
|
372
|
+
dp.Field(
|
|
373
|
+
key="target_total_value_fx_portfolio",
|
|
374
|
+
label="Target Total Value",
|
|
375
|
+
show="open",
|
|
376
|
+
width=Unit.PIXEL(150),
|
|
377
|
+
),
|
|
378
|
+
dp.Field(
|
|
379
|
+
key="total_value_fx_portfolio",
|
|
380
|
+
label="Total Value",
|
|
381
|
+
formatting_rules=[
|
|
382
|
+
dp.FormattingRule(
|
|
383
|
+
style={"color": WBColor.RED_DARK.value, "fontWeight": "bold"},
|
|
384
|
+
condition=("<", 0),
|
|
385
|
+
),
|
|
386
|
+
dp.FormattingRule(
|
|
387
|
+
style={"color": WBColor.GREEN_DARK.value, "fontWeight": "bold"},
|
|
388
|
+
condition=(">", 0),
|
|
389
|
+
),
|
|
390
|
+
],
|
|
391
|
+
width=Unit.PIXEL(150),
|
|
392
|
+
),
|
|
393
|
+
],
|
|
394
|
+
)
|
|
395
|
+
)
|
|
360
396
|
fields.append(
|
|
361
397
|
dp.Field(
|
|
362
398
|
label="Information",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from contextlib import suppress
|
|
2
2
|
from datetime import date
|
|
3
|
+
from decimal import Decimal
|
|
3
4
|
|
|
4
5
|
from django.contrib.messages import info, warning
|
|
5
6
|
from django.shortcuts import get_object_or_404
|
|
@@ -106,8 +107,9 @@ class TradeProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
106
107
|
@action(detail=True, methods=["PATCH"])
|
|
107
108
|
def normalize(self, request, pk=None):
|
|
108
109
|
trade_proposal = get_object_or_404(TradeProposal, pk=pk)
|
|
110
|
+
total_cash_weight = Decimal(request.data.get("total_cash_weight", Decimal("0.0")))
|
|
109
111
|
if trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
110
|
-
trade_proposal.normalize_trades()
|
|
112
|
+
trade_proposal.normalize_trades(total_target_weight=Decimal("1.0") - total_cash_weight)
|
|
111
113
|
return Response({"send": True})
|
|
112
114
|
return Response({"status": "Trade proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
113
115
|
|
|
@@ -25,7 +25,6 @@ from wbcore.permissions.permissions import InternalUserPermissionMixin
|
|
|
25
25
|
from wbcore.utils.strings import format_number
|
|
26
26
|
from wbcore.viewsets.mixins import OrderableMixin
|
|
27
27
|
from wbcrm.models import Account
|
|
28
|
-
from wbfdm.models import Cash
|
|
29
28
|
|
|
30
29
|
from wbportfolio.filters import (
|
|
31
30
|
SubscriptionRedemptionFilterSet,
|
|
@@ -395,6 +394,10 @@ class TradeTradeProposalModelViewSet(
|
|
|
395
394
|
def trade_proposal(self):
|
|
396
395
|
return get_object_or_404(TradeProposal, pk=self.kwargs["trade_proposal_id"])
|
|
397
396
|
|
|
397
|
+
@cached_property
|
|
398
|
+
def portfolio_total_asset_value(self):
|
|
399
|
+
return self.trade_proposal.portfolio_total_asset_value
|
|
400
|
+
|
|
398
401
|
def has_import_permission(self, request) -> bool: # allow import only on draft trade proposal
|
|
399
402
|
return super().has_import_permission(request) and self.trade_proposal.status == TradeProposal.Status.DRAFT
|
|
400
403
|
|
|
@@ -409,46 +412,36 @@ class TradeTradeProposalModelViewSet(
|
|
|
409
412
|
def get_aggregates(self, queryset, *args, **kwargs):
|
|
410
413
|
agg = {}
|
|
411
414
|
if queryset.exists():
|
|
412
|
-
cash_target_position = self.trade_proposal.get_estimated_target_cash(
|
|
413
|
-
self.trade_proposal.portfolio.currency
|
|
414
|
-
)
|
|
415
|
-
cash_target_cash_weight, cash_target_cash_shares = (
|
|
416
|
-
cash_target_position.weighting,
|
|
417
|
-
cash_target_position.initial_shares,
|
|
418
|
-
)
|
|
419
|
-
extra_existing_cash_components = Cash.objects.filter(
|
|
420
|
-
id__in=self.trade_proposal.trades.filter(underlying_instrument__is_cash=True).values(
|
|
421
|
-
"underlying_instrument"
|
|
422
|
-
)
|
|
423
|
-
).exclude(currency=self.trade_proposal.portfolio.currency)
|
|
424
|
-
|
|
425
|
-
for cash_component in extra_existing_cash_components:
|
|
426
|
-
extra_cash_position = self.trade_proposal.get_estimated_target_cash(cash_component.currency)
|
|
427
|
-
cash_target_cash_weight += extra_cash_position.weighting
|
|
428
|
-
cash_target_cash_shares += extra_cash_position.initial_shares
|
|
429
415
|
noncash_aggregates = queryset.filter(underlying_instrument__is_cash=False).aggregate(
|
|
430
416
|
sum_target_weight=Sum(F("target_weight")),
|
|
431
417
|
sum_effective_weight=Sum(F("effective_weight")),
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
)
|
|
435
|
-
cash_aggregates = queryset.filter(underlying_instrument__is_cash=True).aggregate(
|
|
436
|
-
sum_effective_weight=Sum(F("effective_weight")),
|
|
437
|
-
sum_effective_shares=Sum(F("effective_shares")),
|
|
418
|
+
sum_target_total_value_fx_portfolio=Sum(F("target_total_value_fx_portfolio")),
|
|
419
|
+
sum_effective_total_value_fx_portfolio=Sum(F("effective_total_value_fx_portfolio")),
|
|
438
420
|
)
|
|
421
|
+
|
|
439
422
|
# weights aggregates
|
|
440
|
-
cash_sum_effective_weight =
|
|
423
|
+
cash_sum_effective_weight = Decimal("1.0") - noncash_aggregates["sum_effective_weight"]
|
|
424
|
+
cash_sum_target_cash_weight = Decimal("1.0") - noncash_aggregates["sum_target_weight"]
|
|
441
425
|
noncash_sum_effective_weight = noncash_aggregates["sum_effective_weight"] or Decimal(0)
|
|
442
426
|
noncash_sum_target_weight = noncash_aggregates["sum_target_weight"] or Decimal(0)
|
|
443
427
|
sum_buy_weight = queryset.filter(weighting__gte=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
444
428
|
sum_sell_weight = queryset.filter(weighting__lt=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
445
429
|
|
|
446
430
|
# shares aggregates
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
431
|
+
cash_sum_effective_total_value_fx_portfolio = cash_sum_effective_weight * self.portfolio_total_asset_value
|
|
432
|
+
cash_sum_target_total_value_fx_portfolio = cash_sum_target_cash_weight * self.portfolio_total_asset_value
|
|
433
|
+
noncash_sum_effective_total_value_fx_portfolio = noncash_aggregates[
|
|
434
|
+
"sum_effective_total_value_fx_portfolio"
|
|
435
|
+
] or Decimal(0)
|
|
436
|
+
noncash_sum_target_total_value_fx_portfolio = noncash_aggregates[
|
|
437
|
+
"sum_target_total_value_fx_portfolio"
|
|
438
|
+
] or Decimal(0)
|
|
439
|
+
sum_buy_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__gte=0).aggregate(
|
|
440
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
441
|
+
)["s"] or Decimal(0)
|
|
442
|
+
sum_sell_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__lt=0).aggregate(
|
|
443
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
444
|
+
)["s"] or Decimal(0)
|
|
452
445
|
|
|
453
446
|
agg = {
|
|
454
447
|
"effective_weight": {
|
|
@@ -457,29 +450,38 @@ class TradeTradeProposalModelViewSet(
|
|
|
457
450
|
"Total": format_number(noncash_sum_effective_weight + cash_sum_effective_weight, decimal=6),
|
|
458
451
|
},
|
|
459
452
|
"target_weight": {
|
|
460
|
-
"Cash": format_number(
|
|
453
|
+
"Cash": format_number(cash_sum_target_cash_weight, decimal=6),
|
|
461
454
|
"Non-Cash": format_number(noncash_sum_target_weight, decimal=6),
|
|
462
|
-
"Total": format_number(
|
|
455
|
+
"Total": format_number(cash_sum_target_cash_weight + noncash_sum_target_weight, decimal=6),
|
|
463
456
|
},
|
|
464
|
-
"
|
|
465
|
-
"Cash": format_number(
|
|
466
|
-
"Non-Cash": format_number(
|
|
467
|
-
"Total": format_number(
|
|
457
|
+
"effective_total_value_fx_portfolio": {
|
|
458
|
+
"Cash": format_number(cash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
459
|
+
"Non-Cash": format_number(noncash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
460
|
+
"Total": format_number(
|
|
461
|
+
cash_sum_effective_total_value_fx_portfolio + noncash_sum_effective_total_value_fx_portfolio,
|
|
462
|
+
decimal=6,
|
|
463
|
+
),
|
|
468
464
|
},
|
|
469
|
-
"
|
|
470
|
-
"Cash": format_number(
|
|
471
|
-
"Non-Cash": format_number(
|
|
472
|
-
"Total": format_number(
|
|
465
|
+
"target_total_value_fx_portfolio": {
|
|
466
|
+
"Cash": format_number(cash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
467
|
+
"Non-Cash": format_number(noncash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
468
|
+
"Total": format_number(
|
|
469
|
+
cash_sum_target_total_value_fx_portfolio + noncash_sum_target_total_value_fx_portfolio,
|
|
470
|
+
decimal=6,
|
|
471
|
+
),
|
|
473
472
|
},
|
|
474
473
|
"weighting": {
|
|
475
|
-
"Cash Flow": format_number(
|
|
474
|
+
"Cash Flow": format_number(cash_sum_target_cash_weight - cash_sum_effective_weight, decimal=6),
|
|
476
475
|
"Buy": format_number(sum_buy_weight, decimal=6),
|
|
477
476
|
"Sell": format_number(sum_sell_weight, decimal=6),
|
|
478
477
|
},
|
|
479
|
-
"
|
|
480
|
-
"Cash Flow": format_number(
|
|
481
|
-
|
|
482
|
-
|
|
478
|
+
"total_value_fx_portfolio": {
|
|
479
|
+
"Cash Flow": format_number(
|
|
480
|
+
cash_sum_target_total_value_fx_portfolio - cash_sum_effective_total_value_fx_portfolio,
|
|
481
|
+
decimal=6,
|
|
482
|
+
),
|
|
483
|
+
"Buy": format_number(sum_buy_total_value_fx_portfolio, decimal=6),
|
|
484
|
+
"Sell": format_number(sum_sell_total_value_fx_portfolio, decimal=6),
|
|
483
485
|
},
|
|
484
486
|
}
|
|
485
487
|
|
|
@@ -509,6 +511,7 @@ class TradeTradeProposalModelViewSet(
|
|
|
509
511
|
qs = super().get_queryset().filter(trade_proposal=self.kwargs["trade_proposal_id"]).annotate_base_info()
|
|
510
512
|
else:
|
|
511
513
|
qs = TradeProposal.objects.none()
|
|
514
|
+
|
|
512
515
|
return qs.annotate(
|
|
513
516
|
underlying_instrument_isin=F("underlying_instrument__isin"),
|
|
514
517
|
underlying_instrument_ticker=F("underlying_instrument__ticker"),
|
|
@@ -520,4 +523,7 @@ class TradeTradeProposalModelViewSet(
|
|
|
520
523
|
),
|
|
521
524
|
default=F("underlying_instrument__instrument_type__short_name"),
|
|
522
525
|
),
|
|
526
|
+
effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
|
|
527
|
+
target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
|
|
528
|
+
portfolio_currency=F("portfolio__currency__symbol"),
|
|
523
529
|
)
|
|
@@ -250,11 +250,11 @@ wbportfolio/migrations/0077_remove_transaction_currency_and_more.py,sha256=Yf4a3
|
|
|
250
250
|
wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
251
251
|
wbportfolio/models/__init__.py,sha256=HSpa5xwh_MHQaBpNrq9E0CbdEE5Iq-pDLIsPzZ-TRTg,904
|
|
252
252
|
wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
|
|
253
|
-
wbportfolio/models/asset.py,sha256=
|
|
253
|
+
wbportfolio/models/asset.py,sha256=WwbLjpGHzz17yJxrkuTP4MbErqcTJMlKkDrQI92p5II,45490
|
|
254
254
|
wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
|
|
255
255
|
wbportfolio/models/exceptions.py,sha256=3ix0tWUO-O6jpz8f07XIwycw2x3JFRoWzjwil8FVA2Q,52
|
|
256
256
|
wbportfolio/models/indexes.py,sha256=gvW4K9U9Bj8BmVCqFYdWiXvDWhjHINRON8XhNsZUiQY,639
|
|
257
|
-
wbportfolio/models/portfolio.py,sha256=
|
|
257
|
+
wbportfolio/models/portfolio.py,sha256=yQ8cqAZLOMwsiejC9RzOLoqLDV5q5ZWYWMOc2BlcHdw,56281
|
|
258
258
|
wbportfolio/models/portfolio_cash_flow.py,sha256=uElG7IJUBY8qvtrXftOoskX6EA-dKgEG1JJdvHeWV7g,7336
|
|
259
259
|
wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
|
|
260
260
|
wbportfolio/models/portfolio_relationship.py,sha256=ZGECiPZiLdlk4uSamOrEfuzO0hduK6OMKJLUSnh5_kc,5190
|
|
@@ -281,16 +281,16 @@ wbportfolio/models/transactions/claim.py,sha256=SF2FlwG6SRVmA_hT0NbXah5-fYejccWK
|
|
|
281
281
|
wbportfolio/models/transactions/dividends.py,sha256=mmOdGWR35yndUMoCuG24Y6BdtxDhSk2gMQ-8LVguqzg,1890
|
|
282
282
|
wbportfolio/models/transactions/fees.py,sha256=wJtlzbBCAq1UHvv0wqWTE2BEjCF5RMtoaSDS3kODFRo,7112
|
|
283
283
|
wbportfolio/models/transactions/rebalancing.py,sha256=obzgewWKOD4kJbCoF5fhtfDk502QkbrjPKh8T9KDGew,7355
|
|
284
|
-
wbportfolio/models/transactions/trade_proposals.py,sha256=
|
|
285
|
-
wbportfolio/models/transactions/trades.py,sha256=
|
|
284
|
+
wbportfolio/models/transactions/trade_proposals.py,sha256=Jd7rfiE42Qtqp9VXQ448OF9m_DKoFR8ISjSh-1TDi8o,31592
|
|
285
|
+
wbportfolio/models/transactions/trades.py,sha256=og8R4Sg7LCRQsuX09uYjZXq9q4VsUb0j8W8iCTyx0pQ,31571
|
|
286
286
|
wbportfolio/models/transactions/transactions.py,sha256=XTcUeMUfkf5XTSZaR2UAyGqCVkOhQYk03_vzHLIgf8Q,3807
|
|
287
287
|
wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
288
|
-
wbportfolio/pms/typing.py,sha256=
|
|
288
|
+
wbportfolio/pms/typing.py,sha256=qcqEs94HA7qNB631LErV7FVQ4ytYBLetuPDcilrnlW0,6798
|
|
289
289
|
wbportfolio/pms/analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
290
290
|
wbportfolio/pms/analytics/portfolio.py,sha256=vE0KA6Z037bUdmBTkYuBqXElt80nuYObNzY_kWvxEZY,1360
|
|
291
291
|
wbportfolio/pms/statistics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
292
292
|
wbportfolio/pms/trading/__init__.py,sha256=R_yLKc54sCak8A1cW0O1Aszrcv5KV8mC_3h17Hr20e4,36
|
|
293
|
-
wbportfolio/pms/trading/handler.py,sha256=
|
|
293
|
+
wbportfolio/pms/trading/handler.py,sha256=ORvtwAG2VX1GvNvNE2HnDvS-4UNrgBwCMPydnfphtY0,5992
|
|
294
294
|
wbportfolio/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
295
295
|
wbportfolio/rebalancing/base.py,sha256=NwTGZtBm1f35gj5Jp6iTyyFvDT1GSIztN990cKBvYzQ,637
|
|
296
296
|
wbportfolio/rebalancing/decorators.py,sha256=JhQ2vkcIuGBTGvNmpkQerdw2-vLq-RAb0KAPjOrETkw,515
|
|
@@ -298,7 +298,7 @@ wbportfolio/rebalancing/models/__init__.py,sha256=AQjG7Tu5vlmhqncVoYOjpBKU2UIvgo
|
|
|
298
298
|
wbportfolio/rebalancing/models/composite.py,sha256=XEgK3oMurrE_d_l5uN0stBKRrtvnKQzRWyXNXuBYfmc,1818
|
|
299
299
|
wbportfolio/rebalancing/models/equally_weighted.py,sha256=FCpSKOs49ckNYVgoYIiHB0BqPT9OeCMuFoet4Ixbp-Y,1210
|
|
300
300
|
wbportfolio/rebalancing/models/market_capitalization_weighted.py,sha256=6ZsR8iJg6l89CsxHqoxJSXlTaj8Pmb8_bFPrXhnxaRs,5295
|
|
301
|
-
wbportfolio/rebalancing/models/model_portfolio.py,sha256=
|
|
301
|
+
wbportfolio/rebalancing/models/model_portfolio.py,sha256=Dk3tw9u3WG1jCP3V2-R05GS4-DmDBBtxH4h6p7pRe4g,1393
|
|
302
302
|
wbportfolio/reports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
303
303
|
wbportfolio/reports/monthly_position_report.py,sha256=e7BzjDd6eseUOwLwQJXKvWErQ58YnCsznHU2VtR6izM,2981
|
|
304
304
|
wbportfolio/risk_management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -346,8 +346,8 @@ wbportfolio/serializers/transactions/__init__.py,sha256=oAfidhjjCKP0exeHbzJgGuBd
|
|
|
346
346
|
wbportfolio/serializers/transactions/claim.py,sha256=kC4E2RZRrpd9i8tGfoiV-gpWDk3ikR5F1Wf0v_IGIvw,11599
|
|
347
347
|
wbportfolio/serializers/transactions/dividends.py,sha256=ADXf9cXe8rq55lC_a8vIzViGLmQ-yDXkgR54k2m-N0w,1814
|
|
348
348
|
wbportfolio/serializers/transactions/fees.py,sha256=3mBzs6vdfST9roeQB-bmLJhipY7i5jBtAXjoTTE-GOg,2388
|
|
349
|
-
wbportfolio/serializers/transactions/trade_proposals.py,sha256=
|
|
350
|
-
wbportfolio/serializers/transactions/trades.py,sha256=
|
|
349
|
+
wbportfolio/serializers/transactions/trade_proposals.py,sha256=ufYBYiSttz5KAlAaaXbnf98oUFT_qNfxF_VUM4ClXE8,4072
|
|
350
|
+
wbportfolio/serializers/transactions/trades.py,sha256=JtL2jrvIjBVsmB2N5ngw_1UyzK7BbxGK5yuNTCRSfkU,16815
|
|
351
351
|
wbportfolio/static/wbportfolio/css/macro_review.css,sha256=FAVVO8nModxwPXcTKpcfzVxBGPZGJVK1Xn-0dkSfGyc,233
|
|
352
352
|
wbportfolio/static/wbportfolio/markdown/documentation/account_holding_reconciliation.md,sha256=MabxOvOne8s5gl6osDoow6-3ghaXLAYg9THWpvy6G5I,921
|
|
353
353
|
wbportfolio/static/wbportfolio/markdown/documentation/aggregate_asset_position_liquidity.md,sha256=HEgXB7uqmqfty-GBCCXYxrAN-teqmxWuqDLK_liKWVc,1090
|
|
@@ -387,12 +387,12 @@ wbportfolio/tests/models/transactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
|
|
|
387
387
|
wbportfolio/tests/models/transactions/test_claim.py,sha256=NG3BKB-FVcIDgHSJHCjImxgMM3ISVUMl24xUPmEcPec,5570
|
|
388
388
|
wbportfolio/tests/models/transactions/test_fees.py,sha256=tAp18x2wCNQr11LUnLtHNbBDbbX0v1DZnmW7i-cEi5Q,2423
|
|
389
389
|
wbportfolio/tests/models/transactions/test_rebalancing.py,sha256=fZ5tx6kByEGXD6nhapYdvk9HOjYlmjhU2w6KlQJ6QE4,4061
|
|
390
|
-
wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=
|
|
390
|
+
wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=ERGDbniHctEnIG5yFLkrZX2Jw1jEew5rWceVKuKskto,18997
|
|
391
391
|
wbportfolio/tests/models/transactions/test_trades.py,sha256=vqvOqUY_uXvBp8YOKR0Wq9ycA2oeeEBhO3dzV7sbXEU,9863
|
|
392
392
|
wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
393
393
|
wbportfolio/tests/pms/test_analytics.py,sha256=fAuY1zcXibttFpBh2GhKVyzdYfi1kz_b7SPa9xZQXY0,1086
|
|
394
394
|
wbportfolio/tests/rebalancing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
395
|
-
wbportfolio/tests/rebalancing/test_models.py,sha256=
|
|
395
|
+
wbportfolio/tests/rebalancing/test_models.py,sha256=4rSb05xKH881ft12G0B8ZSeFgJvutKcW_7vFgVQa0DI,7813
|
|
396
396
|
wbportfolio/tests/serializers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
397
397
|
wbportfolio/tests/serializers/test_claims.py,sha256=vQrg73xQXRFEgvx3KI9ivFre_wpBFzdO0p0J13PkvdY,582
|
|
398
398
|
wbportfolio/tests/viewsets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -436,7 +436,7 @@ wbportfolio/viewsets/configs/buttons/products.py,sha256=bHOs2ftfaLbIbJ7E8yFqQqSu
|
|
|
436
436
|
wbportfolio/viewsets/configs/buttons/reconciliations.py,sha256=lw4r22GHpqKPUF1MrB6P9dOkL-FHe5iiBJ0--f8D74E,3145
|
|
437
437
|
wbportfolio/viewsets/configs/buttons/registers.py,sha256=aS89TsYHql83k-NHojOrLDqtBpnpsUUO8x63PiMfrXM,445
|
|
438
438
|
wbportfolio/viewsets/configs/buttons/signals.py,sha256=6sKBQI_eDvZuZR5bUUwvur5R67A3oChAGxPfayWUelE,2739
|
|
439
|
-
wbportfolio/viewsets/configs/buttons/trade_proposals.py,sha256=
|
|
439
|
+
wbportfolio/viewsets/configs/buttons/trade_proposals.py,sha256=Os5jT-aL0jMNkdm2sJpGRZvY4OzreR6Ah7eHex_6zB4,3383
|
|
440
440
|
wbportfolio/viewsets/configs/buttons/trades.py,sha256=bPOqqwdgadSUbU9l5aSirqr5UAMFe2AiFNsXLRsKwn8,2535
|
|
441
441
|
wbportfolio/viewsets/configs/display/__init__.py,sha256=jJqSCCAfw_vjbcsIkzKr39LdAZA810LUBfSH1EA7MAI,2086
|
|
442
442
|
wbportfolio/viewsets/configs/display/adjustments.py,sha256=jIOEc23OCYBguLaZRlZxC916kocYT35ZV9Jsiocs9nk,3334
|
|
@@ -456,8 +456,8 @@ wbportfolio/viewsets/configs/display/rebalancing.py,sha256=yw9X1Nf2-V_KP_mCX4pVK
|
|
|
456
456
|
wbportfolio/viewsets/configs/display/reconciliations.py,sha256=YvMAuwmpX0HExvGsuf5UvcRQxe4eMo1iyNJX68GGC_k,6021
|
|
457
457
|
wbportfolio/viewsets/configs/display/registers.py,sha256=1np75exIk5rfct6UkVN_RnfJ9ozvIkcWJgFV4_4rJns,3182
|
|
458
458
|
wbportfolio/viewsets/configs/display/roles.py,sha256=SFUyCdxSlHZ3NsMrJmpVBSlg-XKGaEFteV89nyLMMAQ,1815
|
|
459
|
-
wbportfolio/viewsets/configs/display/trade_proposals.py,sha256=
|
|
460
|
-
wbportfolio/viewsets/configs/display/trades.py,sha256=
|
|
459
|
+
wbportfolio/viewsets/configs/display/trade_proposals.py,sha256=gM5CuUmNcVtwJHYE9Myg-RR_WmAc6VDJztwUqQ1uZ7g,4335
|
|
460
|
+
wbportfolio/viewsets/configs/display/trades.py,sha256=ZYZ2ceE4Hyw2SpQjW00VDnzGz7Wlu3jjPoK_J2GZLyk,19181
|
|
461
461
|
wbportfolio/viewsets/configs/endpoints/__init__.py,sha256=KR3AsSxl71VAVkYBRVowgs3PZB8vaKa34WHHUvC-2MY,2807
|
|
462
462
|
wbportfolio/viewsets/configs/endpoints/adjustments.py,sha256=KRZLqv4ZeB27d-_s7Qlqj9LI3I9WFJkjc6Mw8agDueE,606
|
|
463
463
|
wbportfolio/viewsets/configs/endpoints/assets.py,sha256=B8W6ATQQfT292TllE4sUDYv5wHUCo99HEqymJuMtFAc,1909
|
|
@@ -517,9 +517,9 @@ wbportfolio/viewsets/transactions/claim.py,sha256=Pb1WftoO-w-ZSTbLRhmQubhy7hgd68
|
|
|
517
517
|
wbportfolio/viewsets/transactions/fees.py,sha256=WT2bWWfgozz4_rpyTKX7dgBBTXD-gu0nlsd2Nk2Zh1Q,7028
|
|
518
518
|
wbportfolio/viewsets/transactions/mixins.py,sha256=WipvJoi5hylkpD0y9VATe30WAcwIHUIroVkK10FYw7k,636
|
|
519
519
|
wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQVTN2MH1kG_yCeVkmyK8k,1263
|
|
520
|
-
wbportfolio/viewsets/transactions/trade_proposals.py,sha256=
|
|
521
|
-
wbportfolio/viewsets/transactions/trades.py,sha256=
|
|
522
|
-
wbportfolio-1.52.
|
|
523
|
-
wbportfolio-1.52.
|
|
524
|
-
wbportfolio-1.52.
|
|
525
|
-
wbportfolio-1.52.
|
|
520
|
+
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,,
|
|
File without changes
|
|
File without changes
|