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

Files changed (34) hide show
  1. wbportfolio/constants.py +1 -0
  2. wbportfolio/factories/orders/orders.py +10 -2
  3. wbportfolio/filters/assets.py +10 -2
  4. wbportfolio/import_export/handlers/orders.py +1 -1
  5. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  6. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  7. wbportfolio/models/asset.py +6 -20
  8. wbportfolio/models/builder.py +11 -11
  9. wbportfolio/models/orders/order_proposals.py +53 -20
  10. wbportfolio/models/orders/orders.py +4 -1
  11. wbportfolio/models/portfolio.py +16 -8
  12. wbportfolio/models/products.py +9 -0
  13. wbportfolio/pms/trading/handler.py +0 -1
  14. wbportfolio/serializers/orders/order_proposals.py +2 -18
  15. wbportfolio/tests/conftest.py +6 -2
  16. wbportfolio/tests/models/orders/test_order_proposals.py +61 -15
  17. wbportfolio/tests/models/test_products.py +11 -0
  18. wbportfolio/tests/signals.py +0 -10
  19. wbportfolio/tests/tests.py +2 -0
  20. wbportfolio/viewsets/__init__.py +7 -4
  21. wbportfolio/viewsets/assets.py +1 -215
  22. wbportfolio/viewsets/charts/__init__.py +6 -1
  23. wbportfolio/viewsets/charts/assets.py +337 -154
  24. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  25. wbportfolio/viewsets/configs/display/assets.py +6 -19
  26. wbportfolio/viewsets/configs/display/products.py +1 -1
  27. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +12 -2
  28. wbportfolio/viewsets/orders/order_proposals.py +2 -1
  29. wbportfolio/viewsets/positions.py +3 -2
  30. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/METADATA +3 -1
  31. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/RECORD +33 -32
  32. wbportfolio/viewsets/signals.py +0 -43
  33. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/WHEEL +0 -0
  34. {wbportfolio-1.54.21.dist-info → wbportfolio-1.54.23.dist-info}/licenses/LICENSE +0 -0
@@ -4,6 +4,7 @@ from decimal import Decimal
4
4
  from unittest.mock import call, patch
5
5
 
6
6
  import pytest
7
+ from django.db.models import Sum
7
8
  from faker import Faker
8
9
  from pandas._libs.tslibs.offsets import BDay, BusinessMonthEnd
9
10
 
@@ -50,31 +51,36 @@ class TestOrderProposal:
50
51
  )
51
52
 
52
53
  # Create orders for testing
53
- t1 = order_factory.create(
54
+ o1 = order_factory.create(
54
55
  order_proposal=order_proposal,
55
56
  weighting=Decimal("0.05"),
56
57
  portfolio=order_proposal.portfolio,
57
58
  underlying_instrument=a1.underlying_quote,
58
59
  )
59
- t2 = order_factory.create(
60
+ o2 = order_factory.create(
60
61
  order_proposal=order_proposal,
61
62
  weighting=Decimal("-0.05"),
62
63
  portfolio=order_proposal.portfolio,
63
64
  underlying_instrument=a2.underlying_quote,
64
65
  )
65
66
 
67
+ r1 = o1.price / a1.initial_price - Decimal("1")
68
+ r2 = o2.price / a2.initial_price - Decimal("1")
69
+ p_return = a1.weighting * (Decimal("1") + r1) + a2.weighting * (Decimal("1") + r2)
66
70
  # Get the validated trading service
67
- validated_trading_service = order_proposal.validated_trading_service
71
+ trades = order_proposal.validated_trading_service.trades_batch.trades_map
72
+ t1 = trades[a1.underlying_quote.id]
73
+ t2 = trades[a2.underlying_quote.id]
68
74
 
69
75
  # Assert effective and target portfolios are as expected
70
- assert validated_trading_service._effective_portfolio.to_dict() == {
71
- a1.underlying_quote.id: a1.weighting,
72
- a2.underlying_quote.id: a2.weighting,
73
- }
74
- assert validated_trading_service._target_portfolio.to_dict() == {
75
- a1.underlying_quote.id: a1.weighting + t1.weighting,
76
- a2.underlying_quote.id: a2.weighting + t2.weighting,
77
- }
76
+ assert t1.previous_weight == a1.weighting
77
+ assert t2.previous_weight == a2.weighting
78
+ assert t1.target_weight == pytest.approx(
79
+ a1.weighting * ((r1 + Decimal("1")) / p_return) + o1.weighting, abs=Decimal("1e-8")
80
+ )
81
+ assert t2.target_weight == pytest.approx(
82
+ a2.weighting * ((r2 + Decimal("1")) / p_return) + o2.weighting, abs=Decimal("1e-8")
83
+ )
78
84
 
79
85
  # Test the calculation of the last effective date
80
86
  def test_last_effective_date(self, order_proposal, asset_position_factory):
@@ -361,15 +367,12 @@ class TestOrderProposal:
361
367
  assert order_proposal.orders.get(underlying_instrument=i2).weighting == Decimal("-0.3")
362
368
  assert order_proposal.orders.get(underlying_instrument=cash).weighting == Decimal("0.5")
363
369
 
364
- def test_reset_orders_remove_invalid_orders(self, order_proposal, order_factory, instrument_price_factory):
370
+ def test_reset_orders_remove_invalid_orders(self, order_proposal, order_factory):
365
371
  # create a invalid trade and its price
366
372
  invalid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(0))
367
- instrument_price_factory.create(date=invalid_trade.value_date, instrument=invalid_trade.underlying_instrument)
368
373
 
369
374
  # create a valid trade and its price
370
375
  valid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(1))
371
- instrument_price_factory.create(date=valid_trade.value_date, instrument=valid_trade.underlying_instrument)
372
-
373
376
  order_proposal.reset_orders()
374
377
  assert order_proposal.orders.get(underlying_instrument=valid_trade.underlying_instrument).weighting == Decimal(
375
378
  "1"
@@ -687,3 +690,46 @@ class TestOrderProposal:
687
690
  order.save()
688
691
  assert order.shares == Decimal(0)
689
692
  assert order.weighting == Decimal(0)
693
+
694
+ def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
695
+ order1 = order_factory.create(
696
+ order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")
697
+ )
698
+ order2 = order_factory.create(
699
+ order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.3")
700
+ )
701
+ order_proposal.submit()
702
+ order_proposal.approve()
703
+ order_proposal.save()
704
+
705
+ order1.refresh_from_db()
706
+ order2.refresh_from_db()
707
+ assert order1.desired_target_weight == Decimal("0.5")
708
+ assert order2.desired_target_weight == Decimal("0.5")
709
+ assert order1.weighting == Decimal("0.5")
710
+ assert order2.weighting == Decimal("0.5")
711
+
712
+ order1.desired_target_weight = Decimal("0.7")
713
+ order2.desired_target_weight = Decimal("0.3")
714
+ order1.save()
715
+ order2.save()
716
+
717
+ order_proposal.reset_orders(use_desired_target_weight=True)
718
+ order1.refresh_from_db()
719
+ order2.refresh_from_db()
720
+ assert order1.weighting == Decimal("0.7")
721
+ assert order2.weighting == Decimal("0.3")
722
+
723
+ def test_reset_order_proposal_keeps_target_cash_weight(self, order_factory, order_proposal_factory):
724
+ order_proposal = order_proposal_factory.create(
725
+ total_cash_weight=Decimal("0.02")
726
+ ) # create a OP with total cash weight of 2%
727
+
728
+ # create orders that total weight account for only 50%
729
+ order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
730
+ order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.2"))
731
+
732
+ order_proposal.reset_orders()
733
+ assert order_proposal.get_orders().aggregate(s=Sum("target_weight"))["s"] == Decimal(
734
+ "0.98"
735
+ ), "The total target weight leftover does not equal the stored total cash weight"
@@ -215,3 +215,14 @@ class TestProductModel(PortfolioTestMixin):
215
215
  body=f"The product {product} will be terminated on the {product.delisted_date:%Y-%m-%d}",
216
216
  user=internal_user,
217
217
  )
218
+
219
+ def test_delist_product_disable_report(self, product, report_factory):
220
+ report = report_factory.create(content_object=product, is_active=True)
221
+ assert product.delisted_date is None
222
+ assert report.is_active
223
+
224
+ product.delisted_date = datetime.date.today()
225
+ product.save()
226
+
227
+ report.refresh_from_db()
228
+ assert report.is_active is False
@@ -17,8 +17,6 @@ from wbportfolio.viewsets import (
17
17
  ClaimEntryModelViewSet,
18
18
  CustodianDistributionInstrumentChartViewSet,
19
19
  CustomerDistributionInstrumentChartViewSet,
20
- DistributionChartViewSet,
21
- DistributionTableViewSet,
22
20
  NominalProductChartView,
23
21
  OrderOrderProposalModelViewSet,
24
22
  OrderProposalPortfolioModelViewSet,
@@ -96,14 +94,6 @@ def receive_kwargs_portfolio_role_instrument(sender, *args, **kwargs):
96
94
  return {}
97
95
 
98
96
 
99
- @receiver(custom_update_kwargs, sender=DistributionChartViewSet)
100
- @receiver(custom_update_kwargs, sender=DistributionTableViewSet)
101
- def receive_kwargs_distribution_chart_instrument_id(sender, *args, **kwargs):
102
- if instrument_id := kwargs.get("underlying_instrument_id"):
103
- return {"instrument_id": instrument_id}
104
- return {}
105
-
106
-
107
97
  @receiver(custom_update_kwargs, sender=ProductPerformanceFeesModelViewSet)
108
98
  def receive_kwargs_product_performance_fees(sender, *args, **kwargs):
109
99
  CurrencyFXRatesFactory()
@@ -19,6 +19,8 @@ for key, value in default_config.items():
19
19
  "AccountReconciliationLine",
20
20
  "TopDownPortfolioCompositionPandasAPIView",
21
21
  "CompositionModelPortfolioPandasView",
22
+ "DistributionChartViewSet",
23
+ "DistributionTableViewSet",
22
24
  # "ClaimModelViewSet",
23
25
  # "ClaimModelSerializer",
24
26
  ],
@@ -2,12 +2,16 @@ from .assets import (
2
2
  AssetPositionInstrumentModelViewSet,
3
3
  AssetPositionModelViewSet,
4
4
  AssetPositionPortfolioModelViewSet,
5
- AssetPositionUnderlyingInstrumentChartViewSet,
6
5
  CashPositionPortfolioPandasAPIView,
7
6
  CompositionModelPortfolioPandasView,
8
- ContributorPortfolioChartView,
9
7
  )
10
- from .charts import *
8
+ from .charts import (
9
+ DistributionChartViewSet,
10
+ DistributionTableViewSet,
11
+ AssetPositionUnderlyingInstrumentChartViewSet,
12
+ ContributorPortfolioChartView
13
+
14
+ )
11
15
  from .custodians import CustodianModelViewSet, CustodianRepresentationViewSet
12
16
  from .portfolio_relationship import InstrumentPreferedClassificationThroughProductModelViewSet
13
17
  from .portfolios import (
@@ -23,7 +27,6 @@ from .positions import (
23
27
  )
24
28
  from .registers import RegisterModelViewSet, RegisterRepresentationViewSet
25
29
  from .roles import PortfolioRoleInstrumentModelViewSet, PortfolioRoleModelViewSet
26
- from .signals import *
27
30
  from .rebalancing import RebalancingModelRepresentationViewSet, RebalancerRepresentationViewSet, RebalancerModelViewSet
28
31
  from .transactions import *
29
32
  from .orders import *
@@ -2,23 +2,15 @@ from contextlib import suppress
2
2
  from datetime import date, datetime
3
3
 
4
4
  import pandas as pd
5
- import plotly.graph_objects as go
6
5
  from django.db.models import Exists, F, OuterRef, Q, Sum
7
6
  from django.utils.dateparse import parse_date
8
7
  from django.utils.functional import cached_property
9
- from plotly.subplots import make_subplots
10
8
  from wbcore import viewsets
11
- from wbcore.contrib.currency.models import Currency, CurrencyFXRates
9
+ from wbcore.contrib.currency.models import CurrencyFXRates
12
10
  from wbcore.contrib.io.viewsets import ExportPandasAPIViewSet
13
- from wbcore.filters import DjangoFilterBackend
14
11
  from wbcore.pandas import fields as pf
15
12
  from wbcore.permissions.permissions import InternalUserPermissionMixin
16
13
  from wbcore.serializers import decorator
17
- from wbcore.utils.date import get_date_interval_from_request
18
- from wbcore.utils.figures import (
19
- get_default_timeserie_figure,
20
- get_hovertemplate_timeserie,
21
- )
22
14
  from wbcore.utils.strings import format_number
23
15
  from wbfdm.contrib.metric.viewsets.mixins import InstrumentMetricMixin
24
16
  from wbfdm.models import Instrument
@@ -27,11 +19,8 @@ from wbportfolio.filters import (
27
19
  AssetPositionFilter,
28
20
  AssetPositionInstrumentFilter,
29
21
  AssetPositionPortfolioFilter,
30
- AssetPositionUnderlyingInstrumentChartFilter,
31
22
  CashPositionPortfolioFilterSet,
32
- CompositionContributionChartFilter,
33
23
  CompositionModelPortfolioPandasFilter,
34
- ContributionChartFilter,
35
24
  )
36
25
  from wbportfolio.import_export.resources.assets import AssetPositionResource
37
26
  from wbportfolio.metric.backends.portfolio_base import (
@@ -69,16 +58,12 @@ from .configs import (
69
58
  AssetPositionPortfolioEndpointConfig,
70
59
  AssetPositionPortfolioTitleConfig,
71
60
  AssetPositionTitleConfig,
72
- AssetPositionUnderlyingInstrumentChartEndpointConfig,
73
- AssetPositionUnderlyingInstrumentChartTitleConfig,
74
61
  CashPositionPortfolioDisplayConfig,
75
62
  CashPositionPortfolioEndpointConfig,
76
63
  CashPositionPortfolioTitleConfig,
77
64
  CompositionModelPortfolioPandasDisplayConfig,
78
65
  CompositionModelPortfolioPandasEndpointConfig,
79
66
  CompositionModelPortfolioPandasTitleConfig,
80
- ContributorPortfolioChartEndpointConfig,
81
- ContributorPortfolioChartTitleConfig,
82
67
  )
83
68
  from .mixins import UserPortfolioRequestPermissionMixin
84
69
 
@@ -344,205 +329,6 @@ class CashPositionPortfolioPandasAPIView(
344
329
  return AssetPosition.objects.none()
345
330
 
346
331
 
347
- # ##### CHART VIEWS #####
348
-
349
-
350
- class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
351
- filterset_class = ContributionChartFilter
352
- filter_backends = (DjangoFilterBackend,)
353
- IDENTIFIER = "wbportfolio:portfolio-contributor"
354
- queryset = AssetPosition.objects.all()
355
-
356
- title_config_class = ContributorPortfolioChartTitleConfig
357
- endpoint_config_class = ContributorPortfolioChartEndpointConfig
358
-
359
- ROW_HEIGHT: int = 20
360
-
361
- @property
362
- def min_height(self):
363
- if hasattr(self, "nb_rows"):
364
- return self.nb_rows * self.ROW_HEIGHT
365
- return "300px"
366
-
367
- @cached_property
368
- def hedged_currency(self) -> Currency | None:
369
- if "hedged_currency" in self.request.GET:
370
- with suppress(Currency.DoesNotExist):
371
- return Currency.objects.get(pk=self.request.GET["hedged_currency"])
372
-
373
- @cached_property
374
- def show_lookthrough(self) -> bool:
375
- return self.portfolio.is_composition and self.request.GET.get("show_lookthrough", "false").lower() == "true"
376
-
377
- def get_filterset_class(self, request):
378
- if self.portfolio.is_composition:
379
- return CompositionContributionChartFilter
380
- return ContributionChartFilter
381
-
382
- def get_plotly(self, queryset):
383
- fig = go.Figure()
384
- data = []
385
- if self.show_lookthrough:
386
- d1, d2 = get_date_interval_from_request(self.request)
387
- for _d in pd.date_range(d1, d2):
388
- for pos in self.portfolio.get_lookthrough_positions(_d.date()):
389
- data.append(
390
- [
391
- pos.date,
392
- pos.initial_price,
393
- pos.initial_currency_fx_rate,
394
- pos.underlying_instrument_id,
395
- pos.weighting,
396
- ]
397
- )
398
- else:
399
- data = queryset.annotate_hedged_currency_fx_rate(self.hedged_currency).values_list(
400
- "date", "price", "hedged_currency_fx_rate", "underlying_instrument", "weighting"
401
- )
402
- df = Portfolio.get_contribution_df(data).rename(columns={"group_key": "underlying_instrument"})
403
- if not df.empty:
404
- df = df[["contribution_total", "contribution_forex", "underlying_instrument"]].sort_values(
405
- by="contribution_total", ascending=True
406
- )
407
-
408
- df["instrument_id"] = df.underlying_instrument.map(
409
- dict(Instrument.objects.filter(id__in=df["underlying_instrument"]).values_list("id", "name_repr"))
410
- )
411
- df_forex = df[["instrument_id", "contribution_forex"]]
412
- df_forex = df_forex[df_forex.contribution_forex != 0]
413
-
414
- contribution_equity = df.contribution_total - df.contribution_forex
415
-
416
- text_forex = df_forex.contribution_forex.apply(lambda x: f"{x:,.2%}")
417
- text_equity = contribution_equity.apply(lambda x: f"{x:,.2%}")
418
- setattr(self, "nb_rows", df.shape[0])
419
- fig.add_trace(
420
- go.Bar(
421
- y=df.instrument_id,
422
- x=contribution_equity,
423
- name="Contribution Equity",
424
- orientation="h",
425
- marker=dict(
426
- color="rgba(247,110,91,0.6)",
427
- line=dict(color="rgb(247,110,91,1.0)", width=2),
428
- ),
429
- text=text_equity.values,
430
- textposition="auto",
431
- )
432
- )
433
- fig.add_trace(
434
- go.Bar(
435
- y=df_forex.instrument_id,
436
- x=df_forex.contribution_forex,
437
- name="Contribution Forex",
438
- orientation="h",
439
- marker=dict(
440
- color="rgba(58, 71, 80, 0.6)",
441
- line=dict(color="rgba(58, 71, 80, 1.0)", width=2),
442
- ),
443
- text=text_forex.values,
444
- textposition="outside",
445
- )
446
- )
447
- fig.update_layout(
448
- barmode="relative",
449
- xaxis=dict(showgrid=False, showline=False, zeroline=False, tickformat=".2%"),
450
- yaxis=dict(showgrid=False, showline=False, zeroline=False, tickmode="linear"),
451
- margin=dict(b=0, r=20, l=20, t=0, pad=20),
452
- paper_bgcolor="rgba(0,0,0,0)",
453
- plot_bgcolor="rgba(0,0,0,0)",
454
- font=dict(family="roboto", size=12, color="black"),
455
- bargap=0.3,
456
- )
457
- # fig = get_horizontal_barplot(df, x_label="contribution_total", y_label="name")
458
- return fig
459
-
460
- def parse_figure_dict(self, figure_dict: dict[str, any]) -> dict[str, any]:
461
- figure_dict = super().parse_figure_dict(figure_dict)
462
- figure_dict["style"]["minHeight"] = self.min_height
463
- return figure_dict
464
-
465
- def get_queryset(self):
466
- if self.has_portfolio_access:
467
- return super().get_queryset().filter(portfolio=self.portfolio)
468
- return AssetPosition.objects.none()
469
-
470
-
471
- class AssetPositionUnderlyingInstrumentChartViewSet(UserPortfolioRequestPermissionMixin, viewsets.ChartViewSet):
472
- IDENTIFIER = "wbportfolio:assetpositionchart"
473
-
474
- queryset = AssetPosition.objects.all()
475
-
476
- title_config_class = AssetPositionUnderlyingInstrumentChartTitleConfig
477
- endpoint_config_class = AssetPositionUnderlyingInstrumentChartEndpointConfig
478
- filterset_class = AssetPositionUnderlyingInstrumentChartFilter
479
-
480
- def get_queryset(self):
481
- return AssetPosition.objects.filter(underlying_quote__in=self.instrument.get_descendants(include_self=True))
482
-
483
- def get_plotly(self, queryset):
484
- fig = make_subplots(specs=[[{"secondary_y": True}]])
485
- fig = get_default_timeserie_figure(fig)
486
- if queryset.exists():
487
- df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
488
- df_weight = df_weight.where(pd.notnull(df_weight), 0)
489
- df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
490
- min_date = df_weight["date"].min()
491
- max_date = df_weight["date"].max()
492
-
493
- df_price = (
494
- pd.DataFrame(
495
- self.instrument.prices.filter_only_valid_prices()
496
- .annotate_base_data()
497
- .filter(date__gte=min_date, date__lte=max_date)
498
- .values_list("date", "net_value_usd"),
499
- columns=["date", "price_fx_usd"],
500
- )
501
- .set_index("date")
502
- .sort_index()
503
- )
504
-
505
- fig.add_trace(
506
- go.Scatter(
507
- x=df_price.index, y=df_price.price_fx_usd, mode="lines", marker_color="green", name="Price"
508
- ),
509
- secondary_y=False,
510
- )
511
-
512
- df_weight = pd.DataFrame(queryset.values("date", "weighting", "portfolio__name"))
513
- df_weight = df_weight.where(pd.notnull(df_weight), 0)
514
- df_weight = df_weight.groupby(["date", "portfolio__name"]).sum().reset_index()
515
- for portfolio_name, df_tmp in df_weight.groupby("portfolio__name"):
516
- fig.add_trace(
517
- go.Scatter(
518
- x=df_tmp.date,
519
- y=df_tmp.weighting,
520
- hovertemplate=get_hovertemplate_timeserie(is_percent=True),
521
- mode="lines",
522
- name=f"Allocation: {portfolio_name}",
523
- ),
524
- secondary_y=True,
525
- )
526
-
527
- # Set x-axis title
528
- fig.update_xaxes(title_text="Date")
529
- # Set y-axes titles
530
- fig.update_yaxes(
531
- title_text="<b>Price</b>",
532
- secondary_y=False,
533
- titlefont=dict(color="green"),
534
- tickfont=dict(color="green"),
535
- )
536
- fig.update_yaxes(
537
- title_text="<b>Portfolio Allocation (%)</b>",
538
- secondary_y=True,
539
- titlefont=dict(color="blue"),
540
- tickfont=dict(color="blue"),
541
- )
542
-
543
- return fig
544
-
545
-
546
332
  class CompositionModelPortfolioPandasView(
547
333
  UserPortfolioRequestPermissionMixin, InternalUserPermissionMixin, ExportPandasAPIViewSet
548
334
  ):
@@ -1 +1,6 @@
1
- from .assets import DistributionChartViewSet, DistributionTableViewSet
1
+ from .assets import (
2
+ DistributionChartViewSet,
3
+ DistributionTableViewSet,
4
+ AssetPositionUnderlyingInstrumentChartViewSet,
5
+ ContributorPortfolioChartView
6
+ )