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

@@ -9,6 +9,7 @@ from .assets import ( # ProductAllocationEquityFilter, AssetPositionAPIFilter,
9
9
  CashPositionPortfolioFilterSet,
10
10
  CompositionModelPortfolioPandasFilter,
11
11
  ContributionChartFilter,
12
+ CompositionContributionChartFilter,
12
13
  DistributionFilter,
13
14
  )
14
15
  from .custodians import CustodianFilterSet
@@ -462,6 +462,10 @@ class ContributionChartFilter(wb_filters.FilterSet):
462
462
  fields = {}
463
463
 
464
464
 
465
+ class CompositionContributionChartFilter(ContributionChartFilter):
466
+ show_lookthrough = wb_filters.BooleanFilter(label="Show Lookthrough", default=False, method="fake_filter")
467
+
468
+
465
469
  class AssetPositionUnderlyingInstrumentChartFilter(DateFilterMixin, wb_filters.FilterSet):
466
470
  portfolio = wb_filters.ModelMultipleChoiceFilter(
467
471
  label="Portfolios",
@@ -3,15 +3,17 @@ import datetime as dt
3
3
  from django.db.models import TextChoices
4
4
  from pandas.tseries.offsets import BDay
5
5
  from wbcore import filters as wb_filters
6
- from wbcore.contrib.currency.models import CurrencyFXRates
6
+ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
7
+ from wbcore.contrib.geography.models import Geography
7
8
  from wbcore.pandas.filterset import PandasFilterSetMixin
8
9
  from wbcore.utils.date import current_financial_month
10
+ from wbfdm.models import Classification, Instrument
9
11
 
10
12
  from wbportfolio.filters.assets import (
11
13
  DateFilterMixin,
12
14
  get_latest_end_quarter_date_asset_position,
13
15
  )
14
- from wbportfolio.models import AssetPosition
16
+ from wbportfolio.models import AssetPosition, Portfolio
15
17
 
16
18
 
17
19
  class GroupbyChoice(TextChoices):
@@ -33,16 +35,21 @@ class GroupbyChoice(TextChoices):
33
35
  "PREFERRED_CLASSIFICATION": "classification_id",
34
36
  }[name]
35
37
 
36
- @classmethod
37
- def get_repr(cls, name: str) -> str:
38
- return {
39
- "UNDERLYING_INSTRUMENT": "underlying_instrument__name_repr",
40
- "CURRENCY": "underlying_instrument__currency__key",
41
- "COUNTRY": "underlying_instrument__country__name",
42
- "PORTFOLIO": "portfolio__name",
43
- "PRIMARY_CLASSIFICATION": "classification_title",
44
- "PREFERRED_CLASSIFICATION": "classification_title",
45
- }[name]
38
+ def get_repr(self, ids: list[int]) -> dict[int, str]:
39
+ match self:
40
+ case GroupbyChoice.UNDERLYING_INSTRUMENT:
41
+ return dict(Instrument.objects.filter(id__in=ids).values_list("id", "name_repr"))
42
+ case GroupbyChoice.CURRENCY:
43
+ return dict(Currency.objects.filter(id__in=ids).values_list("id", "key"))
44
+ case GroupbyChoice.COUNTRY:
45
+ return dict(Geography.countries.filter(id__in=ids).values_list("id", "name"))
46
+ case GroupbyChoice.PORTFOLIO:
47
+ return dict(Portfolio.objects.filter(id__in=ids).values_list("id", "name"))
48
+ case GroupbyChoice.PRIMARY_CLASSIFICATION:
49
+ return dict(Classification.objects.filter(id__in=ids).values_list("id", "name"))
50
+ case GroupbyChoice.PREFERRED_CLASSIFICATION:
51
+ return dict(Classification.objects.filter(id__in=ids).values_list("id", "name"))
52
+ raise ValueError("invalid self value")
46
53
 
47
54
 
48
55
  def get_latest_date_based_on_multiple_models(field, request, view):
@@ -24,7 +24,7 @@ from django.db.models.functions import Coalesce
24
24
  from django.db.models.signals import post_save
25
25
  from django.dispatch import receiver
26
26
  from django.utils.functional import cached_property
27
- from wbcore.contrib.currency.models import CurrencyFXRates
27
+ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
28
28
  from wbcore.contrib.io.mixins import ImportMixin
29
29
  from wbcore.signals import pre_merge
30
30
  from wbcore.utils.enum import ChoiceEnum
@@ -77,6 +77,24 @@ class AssetPositionDefaultQueryset(QuerySet):
77
77
  classification_title=Subquery(base_qs.values(ref_title)[:1]),
78
78
  )
79
79
 
80
+ def annotate_hedged_currency_fx_rate(self, hedged_currency: Currency | None) -> QuerySet:
81
+ return self.annotate(
82
+ _is_hedged=Case(
83
+ When(
84
+ underlying_instrument__currency__isnull=False,
85
+ underlying_instrument__currency=hedged_currency,
86
+ then=Value(True),
87
+ ),
88
+ default=Value(False),
89
+ output_field=models.BooleanField(),
90
+ ),
91
+ hedged_currency_fx_rate=Case(
92
+ When(_is_hedged=True, then=Value(Decimal(1.0))),
93
+ default=F("currency_fx_rate"),
94
+ output_field=models.BooleanField(),
95
+ ),
96
+ )
97
+
80
98
 
81
99
  class DefaultAssetPositionManager(models.Manager):
82
100
  def annotate_classification_for_group(
@@ -93,7 +111,10 @@ class DefaultAssetPositionManager(models.Manager):
93
111
  classification_group, classification_height=classification_height
94
112
  )
95
113
 
96
- def get_queryset(self) -> QuerySet["AssetPosition"]:
114
+ def annotate_hedged_currency_fx_rate(self, hedged_currency: Currency | None) -> QuerySet:
115
+ return self.get_queryset().annotate_hedged_currency_fx_rate(hedged_currency)
116
+
117
+ def get_queryset(self) -> AssetPositionDefaultQueryset:
97
118
  return AssetPositionDefaultQueryset(self.model).annotate(
98
119
  adjusting_factor=Coalesce(
99
120
  F("applied_adjustment__cumulative_factor") * F("applied_adjustment__factor"), Decimal(1.0)
@@ -13,16 +13,12 @@ from django.contrib.contenttypes.models import ContentType
13
13
  from django.contrib.postgres.fields import DateRangeField
14
14
  from django.db import models
15
15
  from django.db.models import (
16
- BooleanField,
17
- Case,
18
16
  Exists,
19
17
  F,
20
18
  OuterRef,
21
19
  Q,
22
20
  QuerySet,
23
21
  Sum,
24
- Value,
25
- When,
26
22
  )
27
23
  from django.db.models.signals import post_save
28
24
  from django.dispatch import receiver
@@ -629,11 +625,28 @@ class Portfolio(DeleteToDisableMixin, WBModel):
629
625
  )
630
626
  return df
631
627
 
632
- def get_portfolio_contribution_df(self, start, end, with_cash=True, hedged_currency=None, only_equity=False):
628
+ def get_portfolio_contribution_df(
629
+ self,
630
+ start: date,
631
+ end: date,
632
+ with_cash: bool = True,
633
+ hedged_currency: Currency | None = None,
634
+ only_equity: bool = False,
635
+ ) -> pd.DataFrame:
633
636
  qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
634
637
  if only_equity:
635
638
  qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
636
- return Portfolio.get_contribution_df(qs, hedged_currency=hedged_currency)
639
+ qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
640
+ df = Portfolio.get_contribution_df(
641
+ qs.select_related("underlying_instrument").values_list(
642
+ "date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
643
+ )
644
+ )
645
+ df = df.rename(columns={"group_key": "underlying_instrument"})
646
+ df["underlying_instrument__name_repr"] = df["underlying_instrument"].map(
647
+ dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
648
+ )
649
+ return df
637
650
 
638
651
  def check_related_portfolio_at_date(self, val_date: date, related_portfolio: "Portfolio"):
639
652
  assets = AssetPosition.objects.filter(
@@ -730,6 +743,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
730
743
  ):
731
744
  rel.portfolio.evaluate_rebalancing(val_date)
732
745
  for dependent_portfolio in self.get_child_portfolios(val_date):
746
+ dependent_portfolio.change_at_date(val_date)
733
747
  dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
734
748
 
735
749
  def evaluate_rebalancing(self, val_date: date):
@@ -1088,104 +1102,68 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1088
1102
  return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df.replace([np.inf, -np.inf, np.nan], None)
1089
1103
 
1090
1104
  @classmethod
1091
- def get_contribution_df(
1092
- cls,
1093
- qs,
1094
- need_normalize=False,
1095
- groupby_label_id="underlying_instrument",
1096
- groubpy_label_title="underlying_instrument__name_repr",
1097
- currency_fx_rate_label="currency_fx_rate",
1098
- hedged_currency=None,
1099
- ):
1100
- # qs = AssetPosition.annotate_underlying_instrument(qs)
1101
- weight_label = "weighting" if not need_normalize else "total_value_fx_portfolio"
1102
- qs = qs.annotate(
1103
- is_hedged=Case(
1104
- When(
1105
- underlying_instrument__currency__isnull=False,
1106
- underlying_instrument__currency=hedged_currency,
1107
- then=Value(True),
1108
- ),
1109
- default=Value(False),
1110
- output_field=BooleanField(),
1111
- ),
1112
- coalesce_currency_fx_rate=Case(
1113
- When(is_hedged=True, then=Value(Decimal(1.0))),
1114
- default=F(currency_fx_rate_label),
1115
- output_field=models.BooleanField(),
1116
- ),
1117
- ).select_related("underlying_instrument")
1105
+ def get_contribution_df(cls, data, need_normalize: bool = False):
1118
1106
  df = pd.DataFrame(
1119
- qs.values_list(
1120
- "date",
1121
- "price",
1122
- "coalesce_currency_fx_rate",
1123
- groupby_label_id,
1124
- groubpy_label_title,
1125
- weight_label,
1126
- ),
1107
+ data,
1127
1108
  columns=[
1128
1109
  "date",
1129
1110
  "price",
1130
- "coalesce_currency_fx_rate",
1131
- groupby_label_id,
1132
- groubpy_label_title,
1133
- weight_label,
1111
+ "currency_fx_rate",
1112
+ "group_key",
1113
+ "value",
1134
1114
  ],
1135
1115
  )
1136
1116
  if not df.empty:
1137
- df = df[df[weight_label] != 0]
1117
+ df = df[df["value"] != 0]
1138
1118
  df.date = pd.to_datetime(df.date)
1139
- df["price_fx_portfolio"] = df.price * df.coalesce_currency_fx_rate
1119
+ df["price_fx_portfolio"] = df.price * df.currency_fx_rate
1140
1120
 
1141
- df[["price", "price_fx_portfolio", weight_label, "coalesce_currency_fx_rate"]] = df[
1142
- ["price", "price_fx_portfolio", weight_label, "coalesce_currency_fx_rate"]
1121
+ df[["price", "price_fx_portfolio", "value", "currency_fx_rate"]] = df[
1122
+ ["price", "price_fx_portfolio", "value", "currency_fx_rate"]
1143
1123
  ].astype("float")
1144
1124
 
1145
- df[groupby_label_id] = df[groupby_label_id].fillna(0)
1146
- df[groubpy_label_title] = df[groubpy_label_title].fillna("N/A")
1147
- df_static = df[[groupby_label_id, groubpy_label_title]].groupby(groupby_label_id, dropna=False).first()
1125
+ df["group_key"] = df["group_key"].fillna(0)
1148
1126
 
1149
1127
  df = (
1150
1128
  df[
1151
1129
  [
1152
- groupby_label_id,
1130
+ "group_key",
1153
1131
  "date",
1154
1132
  "price",
1155
1133
  "price_fx_portfolio",
1156
- weight_label,
1157
- "coalesce_currency_fx_rate",
1134
+ "value",
1135
+ "currency_fx_rate",
1158
1136
  ]
1159
1137
  ]
1160
- .groupby(["date", groupby_label_id], dropna=False)
1138
+ .groupby(["date", "group_key"], dropna=False)
1161
1139
  .agg(
1162
1140
  {
1163
1141
  "price": "mean",
1164
1142
  "price_fx_portfolio": "mean",
1165
- weight_label: "sum",
1166
- "coalesce_currency_fx_rate": "mean",
1143
+ "value": "sum",
1144
+ "currency_fx_rate": "mean",
1167
1145
  }
1168
1146
  )
1169
1147
  .reset_index()
1170
1148
  .set_index("date")
1171
1149
  .sort_index()
1172
1150
  )
1173
- df[weight_label] = df[weight_label].fillna(0)
1151
+ df["value"] = df["value"].fillna(0)
1174
1152
  value = df.pivot_table(
1175
1153
  index="date",
1176
- columns=[groupby_label_id],
1177
- values=weight_label,
1154
+ columns=["group_key"],
1155
+ values="value",
1178
1156
  fill_value=0,
1179
1157
  aggfunc="sum",
1180
1158
  )
1181
1159
  weights_ = value
1182
1160
  if need_normalize:
1183
- total_value_price = df[weight_label].groupby("date", dropna=False).sum()
1161
+ total_value_price = df["value"].groupby("date", dropna=False).sum()
1184
1162
  weights_ = value.divide(total_value_price, axis=0)
1185
1163
  prices_usd = (
1186
1164
  df.pivot_table(
1187
1165
  index="date",
1188
- columns=[groupby_label_id],
1166
+ columns=["group_key"],
1189
1167
  values="price_fx_portfolio",
1190
1168
  aggfunc="mean",
1191
1169
  )
@@ -1196,8 +1174,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1196
1174
  rates_fx = (
1197
1175
  df.pivot_table(
1198
1176
  index="date",
1199
- columns=[groupby_label_id],
1200
- values="coalesce_currency_fx_rate",
1177
+ columns=["group_key"],
1178
+ values="currency_fx_rate",
1201
1179
  aggfunc="mean",
1202
1180
  )
1203
1181
  .replace(0, np.nan)
@@ -1220,7 +1198,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1220
1198
 
1221
1199
  res = pd.concat(
1222
1200
  [
1223
- df_static,
1224
1201
  monthly_perf_prices,
1225
1202
  monthly_perf_rates_fx,
1226
1203
  contributions_prices,
@@ -1233,8 +1210,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1233
1210
  axis=1,
1234
1211
  ).reset_index()
1235
1212
  res.columns = [
1236
- groupby_label_id,
1237
- groubpy_label_title,
1213
+ "group_key",
1238
1214
  "performance_total",
1239
1215
  "performance_forex",
1240
1216
  "contribution_total",
@@ -95,12 +95,15 @@ class Rebalancer(ComplexToStringMixin, models.Model):
95
95
  while TradeProposal.objects.filter(
96
96
  portfolio=self.portfolio, status=TradeProposal.Status.FAILED, trade_date=pivot_date
97
97
  ).exists():
98
- pivot_date += BDay(1)
98
+ pivot_date = (pivot_date + BDay(1)).date()
99
99
  return pivot_date
100
100
 
101
101
  def is_valid(self, trade_date: date) -> bool:
102
102
  if TradeProposal.objects.filter(
103
- portfolio=self.portfolio, status=TradeProposal.Status.APPROVED, trade_date=trade_date
103
+ portfolio=self.portfolio,
104
+ status=TradeProposal.Status.APPROVED,
105
+ trade_date=trade_date,
106
+ rebalancing_model__isnull=True,
104
107
  ).exists(): # if a already approved trade proposal exists, we do not allow a re-evaluatioon of the rebalancing (only possible if "replayed")
105
108
  return False
106
109
  for initial_valid_datetime in self.get_rrule(trade_date):
@@ -121,12 +124,13 @@ class Rebalancer(ComplexToStringMixin, models.Model):
121
124
  "rebalancing_model": self.rebalancing_model,
122
125
  },
123
126
  )
124
- logger.info(
125
- f"Getting target portfolio ({self.portfolio}) for rebalancing model {self.rebalancing_model} for trade date {trade_date:%Y-%m-%d}"
126
- )
127
+
127
128
  if trade_proposal.rebalancing_model == self.rebalancing_model:
128
129
  trade_proposal.status = TradeProposal.Status.DRAFT
129
130
  try:
131
+ logger.info(
132
+ f"Getting target portfolio ({self.portfolio}) for rebalancing model {self.rebalancing_model} for trade date {trade_date:%Y-%m-%d}"
133
+ )
130
134
  target_portfolio = self.rebalancing_model.get_target_portfolio(
131
135
  self.portfolio, trade_date, trade_proposal.last_effective_date, **self.parameters
132
136
  )
@@ -18,6 +18,7 @@ from wbcore.enums import RequestType
18
18
  from wbcore.metadata.configs.buttons import ActionButton
19
19
  from wbcore.models import WBModel
20
20
  from wbcore.utils.models import CloneMixin
21
+ from wbfdm.models import InstrumentPrice
21
22
  from wbfdm.models.instruments.instruments import Cash, Instrument
22
23
 
23
24
  from wbportfolio.models.roles import PortfolioRole
@@ -254,6 +255,9 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
254
255
  # we cannot do a bulk-create because Trade is a multi table inheritance
255
256
  try:
256
257
  trade = self.trades.get(underlying_instrument=instrument)
258
+ trade.weighting = trade_dto.delta_weight
259
+ trade.currency_fx_rate = currency_fx_rate
260
+ trade.status = Trade.Status.DRAFT
257
261
  except Trade.DoesNotExist:
258
262
  trade = Trade(
259
263
  underlying_instrument=instrument,
@@ -339,7 +343,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
339
343
  except Exception:
340
344
  raise ValueError("We couldn't estimate the number of shares")
341
345
 
342
- def get_estimated_target_cash(self, currency: Currency) -> tuple[Decimal, Decimal]:
346
+ def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
343
347
  """
344
348
  Estimates the target cash weight and shares for a trade proposal.
345
349
 
@@ -380,7 +384,29 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
380
384
  with suppress(ValueError):
381
385
  total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
382
386
 
383
- return target_cash_weight, total_target_shares
387
+ cash_component = Cash.objects.get_or_create(
388
+ currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
389
+ )[0]
390
+ # otherwise, we create a new position
391
+ underlying_quote_price = InstrumentPrice.objects.get_or_create(
392
+ instrument=cash_component,
393
+ date=self.trade_date,
394
+ calculated=False,
395
+ defaults={"net_value": Decimal(1)},
396
+ )[0]
397
+ return AssetPosition(
398
+ underlying_quote=cash_component,
399
+ portfolio_created=None,
400
+ portfolio=self.portfolio,
401
+ date=self.trade_date,
402
+ weighting=target_cash_weight,
403
+ initial_price=underlying_quote_price.net_value,
404
+ initial_shares=total_target_shares,
405
+ asset_valuation_date=self.trade_date,
406
+ underlying_quote_price=underlying_quote_price,
407
+ currency=cash_component.currency,
408
+ is_estimated=False,
409
+ )
384
410
 
385
411
  # Start FSM logics
386
412
 
@@ -414,23 +440,20 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
414
440
  Trade.objects.bulk_update(trades, ["status", "comment"])
415
441
 
416
442
  # If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
417
- cash_target_cash_weight, cash_target_cash_shares = self.get_estimated_target_cash(self.portfolio.currency)
418
- if cash_target_cash_weight:
419
- cash_component = Cash.objects.get_or_create(
420
- currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
421
- )[0]
422
- self.trades.update_or_create(
423
- underlying_instrument=cash_component,
424
- defaults={
425
- "status": Trade.Status.SUBMIT,
426
- "weighting": cash_target_cash_weight,
427
- "shares": cash_target_cash_shares,
428
- },
429
- )
443
+ estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
444
+ target_portfolio = self.validated_trading_service.target_portfolio
445
+ if estimated_cash_position.weighting:
446
+ if existing_cash_position := target_portfolio.positions_map.get(
447
+ estimated_cash_position.underlying_quote.id
448
+ ):
449
+ existing_cash_position += estimated_cash_position
450
+ else:
451
+ target_portfolio.positions_map[estimated_cash_position.underlying_quote.id] = (
452
+ estimated_cash_position._build_dto()
453
+ )
454
+ target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
430
455
 
431
- self.evaluate_active_rules(
432
- self.trade_date, self.validated_trading_service.target_portfolio, asynchronously=True
433
- )
456
+ self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
434
457
 
435
458
  def can_submit(self):
436
459
  errors = dict()
@@ -486,12 +509,23 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
486
509
  raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
487
510
  trades = []
488
511
  assets = []
512
+ # We do not want to create the estimated cash position if there is not trades in the trade proposal (shouldn't be possible anyway)
513
+ estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
514
+
489
515
  for trade in self.trades.all():
490
516
  with suppress(ValueError):
491
- assets.append(trade.to_asset())
517
+ asset = trade.to_asset()
518
+ # we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
519
+ if asset.underlying_quote != estimated_cash_position.underlying_quote:
520
+ assets.append(asset)
492
521
  trade.status = Trade.Status.EXECUTED
493
522
  trades.append(trade)
494
523
 
524
+ # if there is cash leftover, we create an extra asset position to hold the cash component
525
+ if estimated_cash_position.weighting and len(trades) > 0:
526
+ estimated_cash_position.pre_save()
527
+ assets.append(estimated_cash_position)
528
+
495
529
  Trade.objects.bulk_update(trades, ["status"])
496
530
  self.portfolio.bulk_create_positions(assets, evaluate_rebalancer=False)
497
531
  if replay and self.portfolio.is_manageable:
@@ -5,7 +5,7 @@ from unittest.mock import call, patch
5
5
 
6
6
  import pytest
7
7
  from faker import Faker
8
- from pandas._libs.tslibs.offsets import BDay, MonthEnd
8
+ from pandas._libs.tslibs.offsets import BDay, BusinessMonthEnd
9
9
 
10
10
  from wbportfolio.models import Portfolio, RebalancingModel, TradeProposal
11
11
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
@@ -290,6 +290,25 @@ class TestTradeProposal:
290
290
  assert t2.weighting == Decimal("0.1")
291
291
  assert t3.weighting == Decimal("0.6")
292
292
 
293
+ # build the target portfolio
294
+ new_target_portfolio = PortfolioDTO(
295
+ positions=(
296
+ Position(underlying_instrument=i1.id, date=trade_proposal.trade_date, weighting=Decimal("0.2")),
297
+ Position(underlying_instrument=i2.id, date=trade_proposal.trade_date, weighting=Decimal("0.3")),
298
+ Position(underlying_instrument=i3.id, date=trade_proposal.trade_date, weighting=Decimal("0.5")),
299
+ )
300
+ )
301
+
302
+ trade_proposal.reset_trades(target_portfolio=new_target_portfolio)
303
+ # Refetch the trades for each instrument
304
+ t1.refresh_from_db()
305
+ t2.refresh_from_db()
306
+ t3.refresh_from_db()
307
+ # Assert existing trade weights are correctly updated
308
+ assert t1.weighting == Decimal("-0.5")
309
+ assert t2.weighting == Decimal("0")
310
+ assert t3.weighting == Decimal("0.5")
311
+
293
312
  # Test replaying trade proposals
294
313
  @patch.object(Portfolio, "batch_portfolio")
295
314
  def test_replay(self, mock_fct, trade_proposal_factory):
@@ -303,12 +322,12 @@ class TestTradeProposal:
303
322
  tp1 = trade_proposal_factory.create(
304
323
  portfolio=tp0.portfolio,
305
324
  status=TradeProposal.Status.APPROVED,
306
- trade_date=(tp0.trade_date + MonthEnd(1)).date(),
325
+ trade_date=(tp0.trade_date + BusinessMonthEnd(1)).date(),
307
326
  )
308
327
  tp2 = trade_proposal_factory.create(
309
328
  portfolio=tp0.portfolio,
310
329
  status=TradeProposal.Status.APPROVED,
311
- trade_date=(tp1.trade_date + MonthEnd(1)).date(),
330
+ trade_date=(tp1.trade_date + BusinessMonthEnd(1)).date(),
312
331
  )
313
332
 
314
333
  # Replay trade proposals
@@ -374,8 +393,6 @@ class TestTradeProposal:
374
393
  weighting=Decimal("0.2"),
375
394
  )
376
395
 
377
- target_cash_weight, total_target_shares = trade_proposal.get_estimated_target_cash(
378
- trade_proposal.portfolio.currency
379
- )
380
- assert target_cash_weight == Decimal("0.2") + Decimal("1.0") - (Decimal("0.7") + Decimal("0.2"))
381
- assert total_target_shares == Decimal(1_000_000) * Decimal("0.3")
396
+ target_cash_position = trade_proposal.get_estimated_target_cash(trade_proposal.portfolio.currency)
397
+ assert target_cash_position.weighting == Decimal("0.2") + Decimal("1.0") - (Decimal("0.7") + Decimal("0.2"))
398
+ assert target_cash_position.initial_shares == Decimal(1_000_000) * Decimal("0.3")
@@ -14,6 +14,7 @@ from wbcore.filters import DjangoFilterBackend
14
14
  from wbcore.pandas import fields as pf
15
15
  from wbcore.permissions.permissions import InternalUserPermissionMixin
16
16
  from wbcore.serializers import decorator
17
+ from wbcore.utils.date import get_date_interval_from_request
17
18
  from wbcore.utils.figures import (
18
19
  get_default_timeserie_figure,
19
20
  get_hovertemplate_timeserie,
@@ -28,6 +29,7 @@ from wbportfolio.filters import (
28
29
  AssetPositionPortfolioFilter,
29
30
  AssetPositionUnderlyingInstrumentChartFilter,
30
31
  CashPositionPortfolioFilterSet,
32
+ CompositionContributionChartFilter,
31
33
  CompositionModelPortfolioPandasFilter,
32
34
  ContributionChartFilter,
33
35
  )
@@ -354,19 +356,50 @@ class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewset
354
356
  title_config_class = ContributorPortfolioChartTitleConfig
355
357
  endpoint_config_class = ContributorPortfolioChartEndpointConfig
356
358
 
359
+ @cached_property
360
+ def hedged_currency(self) -> Currency | None:
361
+ if "hedged_currency" in self.request.GET:
362
+ with suppress(Currency.DoesNotExist):
363
+ return Currency.objects.get(pk=self.kwargs["hedged_currency"])
364
+
365
+ @cached_property
366
+ def show_lookthrough(self) -> bool:
367
+ return self.portfolio.is_composition and self.request.GET.get("show_lookthrough", "false").lower() == "true"
368
+
369
+ def get_filterset_class(self, request):
370
+ if self.portfolio.is_composition:
371
+ return CompositionContributionChartFilter
372
+ return ContributionChartFilter
373
+
357
374
  def get_plotly(self, queryset):
358
375
  fig = go.Figure()
359
- hedged_currency = (
360
- Currency.objects.get(id=self.request.GET["hedged_currency"])
361
- if "hedged_currency" in self.request.GET
362
- else None
363
- )
364
- df = Portfolio.get_contribution_df(queryset, hedged_currency=hedged_currency)
376
+ data = []
377
+ if self.show_lookthrough:
378
+ d1, d2 = get_date_interval_from_request(self.request)
379
+ for _d in pd.date_range(d1, d2):
380
+ for pos in self.portfolio.get_lookthrough_positions(_d.date()):
381
+ data.append(
382
+ [
383
+ pos.date,
384
+ pos.initial_price,
385
+ pos.initial_currency_fx_rate,
386
+ pos.underlying_instrument_id,
387
+ pos.weighting,
388
+ ]
389
+ )
390
+ else:
391
+ data = queryset.annotate_hedged_currency_fx_rate(self.hedged_currency).values_list(
392
+ "date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
393
+ )
394
+ df = Portfolio.get_contribution_df(data).rename(columns={"group_key": "underlying_instrument"})
365
395
  if not df.empty:
366
396
  df = df[["contribution_total", "contribution_forex", "underlying_instrument"]].sort_values(
367
397
  by="contribution_total", ascending=True
368
398
  )
369
- df["instrument_id"] = df.underlying_instrument.apply(lambda x: Instrument.objects.get(id=x).name_repr)
399
+
400
+ df["instrument_id"] = df.underlying_instrument.map(
401
+ dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
402
+ )
370
403
  df_forex = df[["instrument_id", "contribution_forex"]]
371
404
  df_forex = df_forex[df_forex.contribution_forex != 0]
372
405
 
@@ -120,7 +120,6 @@ class AssetPositionPandasView(
120
120
  groupby_classification_height = int(request.GET.get("groupby_classification_height", "0"))
121
121
 
122
122
  groupby_id = GroupbyChoice.get_id(groupby.name)
123
- grouby_repr = GroupbyChoice.get_repr(groupby.name)
124
123
  if groupby == GroupbyChoice.PRIMARY_CLASSIFICATION:
125
124
  queryset = queryset.annotate_classification_for_group(
126
125
  primary_classification_group, classification_height=groupby_classification_height, unique=True
@@ -135,18 +134,15 @@ class AssetPositionPandasView(
135
134
  primary_classification_group, classification_height=groupby_classification_height
136
135
  )
137
136
  df = Portfolio.get_contribution_df(
138
- queryset,
137
+ queryset.values_list("date", "price", "currency_fx_rate_usd", groupby_id, "weighting"),
139
138
  need_normalize=True,
140
- groupby_label_id=groupby_id,
141
- groubpy_label_title=grouby_repr,
142
- currency_fx_rate_label="currency_fx_rate_usd",
143
139
  )
144
140
  df = df.rename(
145
141
  columns={
146
- groupby_id: "id",
147
- grouby_repr: "title",
142
+ "group_key": "id",
148
143
  }
149
144
  )
145
+ df["title"] = df["id"].map(groupby.get_repr(df["id"]))
150
146
  if groupby == GroupbyChoice.UNDERLYING_INSTRUMENT.value:
151
147
  df_market_shares = pd.DataFrame(
152
148
  queryset.values("market_share", "date", "underlying_instrument")
@@ -164,7 +160,10 @@ class AssetPositionPandasView(
164
160
  return df
165
161
 
166
162
  def manipulate_dataframe(self, df):
167
- return df.where(pd.notnull(df), 0)
163
+ if "titl" in df.columns:
164
+ df["title"] = df["title"].fillna("N/A").astype(str)
165
+ # df[df["title"].isnull()] = "N/A"
166
+ return df.fillna(0)
168
167
 
169
168
 
170
169
  class AggregatedAssetPositionLiquidityPandasView(InternalUserPermissionMixin, ExportPandasAPIViewSet):
@@ -407,9 +407,13 @@ class TradeTradeProposalModelViewSet(
407
407
  def get_aggregates(self, queryset, *args, **kwargs):
408
408
  agg = {}
409
409
  if queryset.exists():
410
- cash_target_cash_weight, cash_target_cash_shares = self.trade_proposal.get_estimated_target_cash(
410
+ cash_target_position = self.trade_proposal.get_estimated_target_cash(
411
411
  self.trade_proposal.portfolio.currency
412
412
  )
413
+ cash_target_cash_weight, cash_target_cash_shares = (
414
+ cash_target_position.weighting,
415
+ cash_target_position.initial_shares,
416
+ )
413
417
  extra_existing_cash_components = Cash.objects.filter(
414
418
  id__in=self.trade_proposal.trades.filter(underlying_instrument__is_cash=True).values(
415
419
  "underlying_instrument"
@@ -417,11 +421,9 @@ class TradeTradeProposalModelViewSet(
417
421
  ).exclude(currency=self.trade_proposal.portfolio.currency)
418
422
 
419
423
  for cash_component in extra_existing_cash_components:
420
- extra_cash_target_cash_weight, extra_cash_target_cash_shares = (
421
- self.trade_proposal.get_estimated_target_cash(cash_component.currency)
422
- )
423
- cash_target_cash_weight += extra_cash_target_cash_weight
424
- cash_target_cash_shares += extra_cash_target_cash_shares
424
+ extra_cash_position = self.trade_proposal.get_estimated_target_cash(cash_component.currency)
425
+ cash_target_cash_weight += extra_cash_position.weighting
426
+ cash_target_cash_shares += extra_cash_position.initial_shares
425
427
  noncash_aggregates = queryset.filter(underlying_instrument__is_cash=False).aggregate(
426
428
  sum_target_weight=Sum(F("target_weight")),
427
429
  sum_effective_weight=Sum(F("effective_weight")),
@@ -487,7 +489,7 @@ class TradeTradeProposalModelViewSet(
487
489
  return TradeTradeProposalModelSerializer
488
490
 
489
491
  def add_messages(self, request, queryset=None, paginated_queryset=None, instance=None, initial=False):
490
- if queryset is not None and queryset.exists():
492
+ if queryset is not None and queryset.exists() and self.trade_proposal.status != TradeProposal.Status.APPROVED:
491
493
  total_target_weight = queryset.aggregate(c=Sum(F("target_weight")))["c"] or Decimal(0)
492
494
  if round(total_target_weight, 3) != 1:
493
495
  warning(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbportfolio
3
- Version: 1.49.6
3
+ Version: 1.49.8
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  License-File: LICENSE
6
6
  Requires-Dist: cryptography==3.4.*
@@ -74,14 +74,14 @@ wbportfolio/factories/trades.py,sha256=-14d_ABN9FEoXb4wm9-YjYHfP2cCEANNM45cMRZtQ
74
74
  wbportfolio/factories/transactions.py,sha256=jZqTNRpW344DSYjV5tAqU5y30Kc7GRGbZ3BN9hacKpQ,995
75
75
  wbportfolio/fdm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
76
76
  wbportfolio/fdm/tasks.py,sha256=59hXt3TOJaM8KpUaKdBNAGsO7NFMHaAlNA0T3FTUau0,533
77
- wbportfolio/filters/__init__.py,sha256=ygkjTc5ZZOkzqwKi4fJ6K_ryx2IclgAKY5MeVGfEY_0,1334
78
- wbportfolio/filters/assets.py,sha256=NlZO9o7UzQPGEt3nbpRKwSiEt0GSeV77OMcx2igwdfU,17934
77
+ wbportfolio/filters/__init__.py,sha256=m44CdyYMtku8rMGyiX8KSmMdvyqaElKW4UMkEqOC1VM,1374
78
+ wbportfolio/filters/assets.py,sha256=gpCwxlje1mY9IGlxfvQyNgyE5RcT-r3B2tI14hB92X4,18114
79
79
  wbportfolio/filters/assets_and_net_new_money_progression.py,sha256=dgI3WWlfYjaDg15-C4Sh2HQoY0kQh9mQUWxn4SAPLnM,1403
80
80
  wbportfolio/filters/custodians.py,sha256=pStQgPQPhPpnt57_V7BuXbFXmRiZBEAiEeMFuQmt2NE,287
81
81
  wbportfolio/filters/esg.py,sha256=sQ5HwiUlhfKeFqJ2O5koXyTscpN21UKRUSi8d90jSlI,688
82
82
  wbportfolio/filters/performances.py,sha256=fAMb8HkWSmBl1xg5P4MZ8WJsV5x7uCcBf7ETHdfXjkI,7325
83
83
  wbportfolio/filters/portfolios.py,sha256=IqkDj3POQ1MHGZO9k7TNuhf5fZ_yR9vPPcMFELkQsnQ,1469
84
- wbportfolio/filters/positions.py,sha256=ENUEtrlGIJSUEe8bhgebXp0ZHskPDM2Nh5qT72_sbTc,7900
84
+ wbportfolio/filters/positions.py,sha256=ERdcTCNw7peGstKSl3W5t1wlSUM8a3anoesoycohF98,8548
85
85
  wbportfolio/filters/products.py,sha256=_QFL3I132q-3JUDji_Wh2Qkv45DKnQrR3N8_luGK0K0,6492
86
86
  wbportfolio/filters/roles.py,sha256=-0tOl-WJDqwXAfcJePpkjQfZB9o13I99euem5XGpMjk,920
87
87
  wbportfolio/filters/signals.py,sha256=3GrsxPwZg-6bvvZ222VfF212yNTJYQP4n5hWIEnTtpA,3144
@@ -245,11 +245,11 @@ wbportfolio/migrations/0076_alter_dividendtransaction_price_and_more.py,sha256=4
245
245
  wbportfolio/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
246
246
  wbportfolio/models/__init__.py,sha256=IIS_PNRxyX2Dcvyk1bcQOUzFt0B9SPC0WlM88CXqj04,881
247
247
  wbportfolio/models/adjustments.py,sha256=osXWkJZOiansPWYPyHtl7Z121zDWi7u1YMtrBQtbHVo,10272
248
- wbportfolio/models/asset.py,sha256=QftWva0GmlStpspcp33TOMZf2t_PTeEhwWdp6xUbQNU,37738
248
+ wbportfolio/models/asset.py,sha256=H4UvrEstrCCbRG_poF68Ih4JFgHE08guMkyhNCZ7QFQ,38636
249
249
  wbportfolio/models/custodians.py,sha256=owTiS2Vm5CRKzh9M_P9GOVg-s-ndQ9UvRmw3yZP7cw0,3815
250
250
  wbportfolio/models/exceptions.py,sha256=3ix0tWUO-O6jpz8f07XIwycw2x3JFRoWzjwil8FVA2Q,52
251
251
  wbportfolio/models/indexes.py,sha256=iLYF2gzNzX4GLj_Nh3fybUcAQ1TslnT0wgQ6mN164QI,728
252
- wbportfolio/models/portfolio.py,sha256=NxJVUtIUxmm_iHnO9UU0_HWmGOwExTz5NZRzHdehhj0,59718
252
+ wbportfolio/models/portfolio.py,sha256=dUj1iPZke17qYk2zSSL-6Mcjr7cKAoq_wIrZtEmTmLg,58666
253
253
  wbportfolio/models/portfolio_cash_flow.py,sha256=2blPiXSw7dbhUVd-7LcxDBb4v0SheNOdvRK3MFYiChA,7273
254
254
  wbportfolio/models/portfolio_cash_targets.py,sha256=WmgG-etPisZsh2yaFQpz7EkpvAudKBEzqPsO715w52U,1498
255
255
  wbportfolio/models/portfolio_relationship.py,sha256=mMb18UMRWg9kx_9uIPkMktwORuXXLjKdgRPQQvB6fVE,5486
@@ -275,8 +275,8 @@ wbportfolio/models/transactions/claim.py,sha256=hcx_tJ9luf2-s1qqsUZXtoDEuxFyty2A
275
275
  wbportfolio/models/transactions/dividends.py,sha256=92-jG8bZN9nU9oDubpu-UDH43Ri7kVjhqE_esOSmOzo,471
276
276
  wbportfolio/models/transactions/expiry.py,sha256=vnNHdcC1hf2HP4rAbmoGgOfagBYKNFytqOwzOI0MlVI,144
277
277
  wbportfolio/models/transactions/fees.py,sha256=ffvqo8I4A0l5rLi00jJ6sGot0jmnkoxaNsbDzdPLwCg,5712
278
- wbportfolio/models/transactions/rebalancing.py,sha256=nrBi6x6PssCtkLtOpV-2OoAHDUKnnYyrM6xH1PmnjSo,7240
279
- wbportfolio/models/transactions/trade_proposals.py,sha256=qpjjQMK7G5sX7iiCnXBt07EH-ztxZz-l842yfYrBLQw,27764
278
+ wbportfolio/models/transactions/rebalancing.py,sha256=obzgewWKOD4kJbCoF5fhtfDk502QkbrjPKh8T9KDGew,7355
279
+ wbportfolio/models/transactions/trade_proposals.py,sha256=a4bxSn6xGuURwsE-WqCJN5gBdwu9TpCf2E8kKn4_RiY,29659
280
280
  wbportfolio/models/transactions/trades.py,sha256=NA_hUpFKQ4H-w3B0gMZ5IFkZo6WJTH2S7GBUQC1jUpU,28922
281
281
  wbportfolio/models/transactions/transactions.py,sha256=fWoDf0TSV0L0gLUDOQpCRLzjMt1H4MUvUHGEaMsilCc,7027
282
282
  wbportfolio/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -384,7 +384,7 @@ wbportfolio/tests/models/transactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCe
384
384
  wbportfolio/tests/models/transactions/test_claim.py,sha256=NG3BKB-FVcIDgHSJHCjImxgMM3ISVUMl24xUPmEcPec,5570
385
385
  wbportfolio/tests/models/transactions/test_fees.py,sha256=1gp_h_CCC4Z_cWHUgrZCjGAxYuT2u8FZdw0krDpESiY,2801
386
386
  wbportfolio/tests/models/transactions/test_rebalancing.py,sha256=fZ5tx6kByEGXD6nhapYdvk9HOjYlmjhU2w6KlQJ6QE4,4061
387
- wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=sqC8uT46Znb03ZwWxIJOaFNBENKunfA5U73xjWbdj80,17053
387
+ wbportfolio/tests/models/transactions/test_trade_proposals.py,sha256=P05F1RC53xjkp8BFq8D57k-86eAOkRQw6doLVX5SQlM,17945
388
388
  wbportfolio/tests/models/transactions/test_trades.py,sha256=vqvOqUY_uXvBp8YOKR0Wq9ycA2oeeEBhO3dzV7sbXEU,9863
389
389
  wbportfolio/tests/pms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
390
390
  wbportfolio/tests/pms/test_analytics.py,sha256=fAuY1zcXibttFpBh2GhKVyzdYfi1kz_b7SPa9xZQXY0,1086
@@ -400,7 +400,7 @@ wbportfolio/tests/viewsets/transactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5J
400
400
  wbportfolio/tests/viewsets/transactions/test_claims.py,sha256=QEZfMAW07dyoZ63t2umSwGOqvaTULfYfbN_F4ZoSAcw,6368
401
401
  wbportfolio/viewsets/__init__.py,sha256=3kUaQ66ybvROwejd3bEcSt4XKzfOlPDaeoStMvlz7qY,2294
402
402
  wbportfolio/viewsets/adjustments.py,sha256=5hWCjxSgUIsrPOmJKoDYK3gywdMTI0aYDorEj1FXRVc,1429
403
- wbportfolio/viewsets/assets.py,sha256=CrAMuyJDyUcSZlckrhy5D6-vVRXD7I4EG9C3ZKA5Z08,22735
403
+ wbportfolio/viewsets/assets.py,sha256=FgJhBK13tAkrDyTiWsgnIT0Oq39mlvJTQSq1ztopQhY,24172
404
404
  wbportfolio/viewsets/assets_and_net_new_money_progression.py,sha256=Jl4vEQP4N2OFL5IGBXoKcj-0qaPviU0I8npvQLw4Io0,4464
405
405
  wbportfolio/viewsets/custodians.py,sha256=CTFqkqVP1R3AV7lhdvcdICxB5DfwDYCyikNSI5kbYEo,2322
406
406
  wbportfolio/viewsets/esg.py,sha256=27MxxdXQH3Cq_1UEYmcrF7htUOg6i81fUpbVQXAAKJI,6985
@@ -410,7 +410,7 @@ wbportfolio/viewsets/portfolio_cash_targets.py,sha256=CvHlrDE8qnnnfRpTYnFu-Uu15M
410
410
  wbportfolio/viewsets/portfolio_relationship.py,sha256=RGyvxd8NfFEs8YdqEvVD3VbrISvAO5UtCTlocSIuWQw,2109
411
411
  wbportfolio/viewsets/portfolio_swing_pricing.py,sha256=-57l3WLQZRslIV67OT0ucHE5JXTtTtLvd3t7MppdVn8,357
412
412
  wbportfolio/viewsets/portfolios.py,sha256=1POzE9jrt2iLVMnIY_BWDr0A_zpOlO3Z8tM80TaXkhk,14454
413
- wbportfolio/viewsets/positions.py,sha256=MDf_0x9La2qE6qjaIqBtfV5VC0RfJ1chZIim45Emk10,13198
413
+ wbportfolio/viewsets/positions.py,sha256=2rzFHB_SI09rXC_EYi58G_eqvzONbk8z61JDkkjt3Ew,13207
414
414
  wbportfolio/viewsets/product_groups.py,sha256=YvmuXPPy98K1J_rz6YPsx9gNK-tCS2P-wc1uRYgfyo0,2399
415
415
  wbportfolio/viewsets/product_performance.py,sha256=dRfRgifjGS1RgZSu9uJRM0SmB7eLnNUkPuqARMO4gyo,28371
416
416
  wbportfolio/viewsets/products.py,sha256=1KXUDXdNmBFYjQcvJkwUCnjIOKZT0cAk_XTihMKRWvw,20459
@@ -519,9 +519,9 @@ wbportfolio/viewsets/transactions/fees.py,sha256=7VUXIogmRrXCz_D9tvDiiTae0t5j09W
519
519
  wbportfolio/viewsets/transactions/mixins.py,sha256=WipvJoi5hylkpD0y9VATe30WAcwIHUIroVkK10FYw7k,636
520
520
  wbportfolio/viewsets/transactions/rebalancing.py,sha256=6rIrdK0rtKL1afJ-tYfAGdQVTN2MH1kG_yCeVkmyK8k,1263
521
521
  wbportfolio/viewsets/transactions/trade_proposals.py,sha256=iQpC_Thbj56SmM05vPRsF1JZguGBDaTUH3I-_iCHCV0,5958
522
- wbportfolio/viewsets/transactions/trades.py,sha256=xeEzx7GP34aBNPlDmiUmT86labsbb8_f1U2RCN1Jatg,21494
522
+ wbportfolio/viewsets/transactions/trades.py,sha256=-yJ4j8NJTu2VWyhCq5BXGNND_925Ietoxx9k07SLVh0,21634
523
523
  wbportfolio/viewsets/transactions/transactions.py,sha256=ixDp-nsNA8t_A06rBCT19hOMJHy0iRmdz1XKdV1OwAs,4450
524
- wbportfolio-1.49.6.dist-info/METADATA,sha256=KMGYS4c7qMFyTTr5hrm23nCftQHaW2JSIRsfp0SetKA,734
525
- wbportfolio-1.49.6.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
526
- wbportfolio-1.49.6.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
527
- wbportfolio-1.49.6.dist-info/RECORD,,
524
+ wbportfolio-1.49.8.dist-info/METADATA,sha256=tEN7pGOUaI3BhAt-b4IyD7LRCi1jWgqwW6linsZA4zQ,734
525
+ wbportfolio-1.49.8.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
526
+ wbportfolio-1.49.8.dist-info/licenses/LICENSE,sha256=jvfVH0SY8_YMHlsJHKe_OajiscQDz4lpTlqT6x24sVw,172
527
+ wbportfolio-1.49.8.dist-info/RECORD,,