wbportfolio 1.52.0__py2.py3-none-any.whl → 1.59.4__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/admin/__init__.py +3 -1
- wbportfolio/admin/indexes.py +1 -1
- wbportfolio/admin/orders/__init__.py +2 -0
- wbportfolio/admin/orders/order_proposals.py +16 -0
- wbportfolio/admin/orders/orders.py +32 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -2
- wbportfolio/admin/transactions/dividends.py +40 -4
- wbportfolio/admin/transactions/fees.py +24 -14
- wbportfolio/admin/transactions/trades.py +34 -27
- wbportfolio/analysis/claims.py +5 -6
- wbportfolio/api_clients/ubs.py +162 -0
- wbportfolio/constants.py +1 -0
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
- wbportfolio/defaults/fees/default.py +7 -15
- wbportfolio/factories/__init__.py +2 -2
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/dividends.py +8 -3
- wbportfolio/factories/fees.py +8 -4
- wbportfolio/factories/orders/__init__.py +2 -0
- wbportfolio/factories/orders/order_proposals.py +21 -0
- wbportfolio/factories/orders/orders.py +34 -0
- wbportfolio/factories/portfolios.py +2 -1
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/factories/trades.py +12 -16
- wbportfolio/filters/assets.py +18 -4
- wbportfolio/filters/orders/__init__.py +2 -0
- wbportfolio/filters/orders/order_proposals.py +55 -0
- wbportfolio/filters/orders/orders.py +11 -0
- wbportfolio/filters/portfolios.py +38 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/__init__.py +1 -2
- wbportfolio/filters/transactions/fees.py +5 -12
- wbportfolio/filters/transactions/trades.py +16 -8
- wbportfolio/filters/transactions/utils.py +42 -0
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +22 -10
- wbportfolio/import_export/handlers/dividend.py +8 -8
- wbportfolio/import_export/handlers/fees.py +13 -23
- wbportfolio/import_export/handlers/orders.py +71 -0
- wbportfolio/import_export/handlers/trade.py +53 -77
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +4 -4
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
- wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -6
- wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
- wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/fees.py +7 -9
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +10 -10
- wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +5 -5
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
- wbportfolio/import_export/parsers/ubs/equity.py +3 -2
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
- wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
- wbportfolio/import_export/parsers/vontobel/management_fees.py +12 -20
- wbportfolio/import_export/parsers/vontobel/performance_fees.py +5 -8
- wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
- wbportfolio/import_export/resources/trades.py +3 -3
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
- wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
- wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
- wbportfolio/migrations/0079_alter_trade_drift_factor.py +19 -0
- wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
- wbportfolio/migrations/0081_alter_trade_drift_factor.py +19 -0
- wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
- wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
- wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
- wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/__init__.py +2 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +28 -170
- wbportfolio/models/builder.py +323 -0
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/mixins/liquidity_stress_test.py +4 -4
- wbportfolio/models/orders/__init__.py +2 -0
- wbportfolio/models/orders/order_proposals.py +1414 -0
- wbportfolio/models/orders/orders.py +410 -0
- wbportfolio/models/portfolio.py +311 -289
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +12 -0
- wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +40 -27
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/__init__.py +0 -4
- wbportfolio/models/transactions/claim.py +7 -6
- wbportfolio/models/transactions/dividends.py +42 -5
- wbportfolio/models/transactions/fees.py +55 -22
- wbportfolio/models/transactions/trades.py +121 -442
- wbportfolio/models/transactions/transactions.py +78 -158
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +35 -0
- wbportfolio/order_routing/adapters/__init__.py +65 -0
- wbportfolio/order_routing/adapters/ubs.py +195 -0
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/__init__.py +0 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/analytics/portfolio.py +17 -9
- wbportfolio/pms/analytics/utils.py +9 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +198 -63
- wbportfolio/rebalancing/base.py +12 -1
- wbportfolio/rebalancing/decorators.py +1 -1
- wbportfolio/rebalancing/models/composite.py +4 -8
- wbportfolio/rebalancing/models/equally_weighted.py +13 -11
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +21 -14
- wbportfolio/rebalancing/models/model_portfolio.py +14 -18
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/__init__.py +1 -0
- wbportfolio/serializers/orders/__init__.py +2 -0
- wbportfolio/serializers/orders/order_proposals.py +115 -0
- wbportfolio/serializers/orders/orders.py +283 -0
- wbportfolio/serializers/portfolios.py +7 -7
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/serializers/signals.py +9 -12
- wbportfolio/serializers/transactions/__init__.py +1 -10
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/serializers/transactions/dividends.py +37 -9
- wbportfolio/serializers/transactions/fees.py +39 -10
- wbportfolio/serializers/transactions/trades.py +55 -157
- wbportfolio/tasks.py +43 -5
- wbportfolio/tests/analysis/__init__.py +0 -0
- wbportfolio/tests/analysis/test_claims.py +85 -0
- wbportfolio/tests/conftest.py +12 -12
- wbportfolio/tests/models/orders/__init__.py +0 -0
- wbportfolio/tests/models/orders/test_order_proposals.py +1046 -0
- wbportfolio/tests/models/test_assets.py +7 -3
- wbportfolio/tests/models/test_imports.py +9 -13
- wbportfolio/tests/models/test_portfolios.py +102 -95
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_fees.py +7 -13
- wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
- wbportfolio/tests/models/transactions/test_trades.py +0 -20
- wbportfolio/tests/pms/test_analytics.py +22 -3
- wbportfolio/tests/rebalancing/test_models.py +51 -57
- wbportfolio/tests/signals.py +10 -20
- wbportfolio/tests/tests.py +3 -1
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +10 -13
- wbportfolio/viewsets/__init__.py +9 -4
- wbportfolio/viewsets/assets.py +3 -204
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +344 -154
- wbportfolio/viewsets/configs/buttons/__init__.py +2 -2
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +4 -4
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/__init__.py +2 -5
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/fees.py +3 -3
- wbportfolio/viewsets/configs/display/portfolios.py +5 -5
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/configs/display/trades.py +1 -189
- wbportfolio/viewsets/configs/endpoints/__init__.py +3 -7
- wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
- wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
- wbportfolio/viewsets/configs/menu/__init__.py +1 -1
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/configs/titles/__init__.py +2 -3
- wbportfolio/viewsets/configs/titles/fees.py +4 -8
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/mixins.py +5 -1
- wbportfolio/viewsets/orders/__init__.py +6 -0
- wbportfolio/viewsets/orders/configs/__init__.py +4 -0
- wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +188 -0
- wbportfolio/viewsets/orders/configs/buttons/orders.py +113 -0
- wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +157 -0
- wbportfolio/viewsets/orders/configs/displays/orders.py +232 -0
- wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +28 -0
- wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
- wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
- wbportfolio/viewsets/orders/order_proposals.py +252 -0
- wbportfolio/viewsets/orders/orders.py +277 -0
- wbportfolio/viewsets/portfolios.py +36 -12
- wbportfolio/viewsets/positions.py +3 -2
- wbportfolio/viewsets/products.py +6 -6
- wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
- wbportfolio/viewsets/transactions/__init__.py +3 -14
- wbportfolio/viewsets/transactions/fees.py +22 -22
- wbportfolio/viewsets/transactions/trades.py +1 -180
- {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +3 -1
- {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +252 -203
- {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/admin/transactions/transactions.py +0 -38
- wbportfolio/factories/transactions.py +0 -22
- wbportfolio/fdm/tasks.py +0 -13
- wbportfolio/filters/transactions/transactions.py +0 -99
- wbportfolio/models/transactions/expiry.py +0 -7
- wbportfolio/models/transactions/trade_proposals.py +0 -704
- wbportfolio/pms/trading/handler.py +0 -161
- wbportfolio/serializers/transactions/expiry.py +0 -18
- wbportfolio/serializers/transactions/trade_proposals.py +0 -76
- wbportfolio/serializers/transactions/transactions.py +0 -85
- wbportfolio/tests/models/transactions/test_trade_proposals.py +0 -410
- wbportfolio/viewsets/configs/buttons/trade_proposals.py +0 -66
- wbportfolio/viewsets/configs/display/trade_proposals.py +0 -100
- wbportfolio/viewsets/configs/display/transactions.py +0 -55
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
- wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
- wbportfolio/viewsets/configs/menu/transactions.py +0 -9
- wbportfolio/viewsets/configs/titles/transactions.py +0 -9
- wbportfolio/viewsets/signals.py +0 -43
- wbportfolio/viewsets/transactions/trade_proposals.py +0 -139
- wbportfolio/viewsets/transactions/transactions.py +0 -122
- /wbportfolio/{fdm → api_clients}/__init__.py +0 -0
- {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/portfolio.py
CHANGED
|
@@ -1,42 +1,36 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections import defaultdict
|
|
2
3
|
from contextlib import suppress
|
|
3
4
|
from datetime import date, timedelta
|
|
4
5
|
from decimal import Decimal
|
|
5
|
-
from
|
|
6
|
-
from typing import TYPE_CHECKING, Any, Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generator, Iterable
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import pandas as pd
|
|
10
10
|
from celery import shared_task
|
|
11
|
-
from django.contrib.contenttypes.models import ContentType
|
|
12
11
|
from django.contrib.postgres.fields import DateRangeField
|
|
12
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
13
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
13
14
|
from django.db import models
|
|
14
|
-
from django.db.models import
|
|
15
|
-
Exists,
|
|
16
|
-
F,
|
|
17
|
-
OuterRef,
|
|
18
|
-
Q,
|
|
19
|
-
QuerySet,
|
|
20
|
-
Sum,
|
|
21
|
-
Value,
|
|
22
|
-
)
|
|
15
|
+
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
|
|
23
16
|
from django.db.models.signals import post_save
|
|
24
17
|
from django.dispatch import receiver
|
|
25
18
|
from django.utils import timezone
|
|
26
19
|
from django.utils.functional import cached_property
|
|
27
20
|
from pandas._libs.tslibs.offsets import BDay
|
|
28
|
-
from
|
|
29
|
-
from wbcore.contrib.currency.models import Currency
|
|
21
|
+
from wbcore.contrib.authentication.models import User
|
|
22
|
+
from wbcore.contrib.currency.models import Currency
|
|
23
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
30
24
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
31
25
|
from wbcore.models import WBModel
|
|
32
26
|
from wbcore.utils.importlib import import_from_dotted_path
|
|
33
27
|
from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
|
|
34
|
-
from wbfdm.
|
|
35
|
-
from wbfdm.models import Instrument, InstrumentType
|
|
28
|
+
from wbfdm.models import Cash, Instrument, InstrumentType
|
|
36
29
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
37
30
|
from wbfdm.signals import investable_universe_updated
|
|
38
31
|
|
|
39
|
-
from wbportfolio.models.asset import AssetPosition
|
|
32
|
+
from wbportfolio.models.asset import AssetPosition
|
|
33
|
+
from wbportfolio.models.builder import AssetPositionBuilder
|
|
40
34
|
from wbportfolio.models.indexes import Index
|
|
41
35
|
from wbportfolio.models.portfolio_relationship import (
|
|
42
36
|
InstrumentPortfolioThroughModel,
|
|
@@ -45,78 +39,17 @@ from wbportfolio.models.portfolio_relationship import (
|
|
|
45
39
|
from wbportfolio.models.products import Product
|
|
46
40
|
from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
47
41
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
42
|
+
from wbportfolio.pms.typing import Position as PositionDTO
|
|
48
43
|
|
|
49
|
-
from
|
|
50
|
-
from .
|
|
44
|
+
from ..constants import EQUITY_TYPE_KEYS
|
|
45
|
+
from ..order_routing.adapters import BaseCustodianAdapter
|
|
46
|
+
from . import PortfolioRole, ProductGroup
|
|
51
47
|
|
|
52
48
|
logger = logging.getLogger("pms")
|
|
53
49
|
if TYPE_CHECKING:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def get_prices(instrument_ids: list[int], from_date: date, to_date: date) -> dict[date, dict[int, float]]:
|
|
58
|
-
"""
|
|
59
|
-
Utility to fetch raw prices
|
|
60
|
-
"""
|
|
61
|
-
prices = InstrumentPrice.objects.filter(instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date)
|
|
62
|
-
df = (
|
|
63
|
-
pd.DataFrame(
|
|
64
|
-
prices.filter_only_valid_prices().values_list("instrument", "net_value", "date"),
|
|
65
|
-
columns=["instrument", "net_value", "date"],
|
|
66
|
-
)
|
|
67
|
-
.pivot_table(index="date", values="net_value", columns="instrument")
|
|
68
|
-
.astype(float)
|
|
69
|
-
.sort_index()
|
|
70
|
-
)
|
|
71
|
-
ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
|
|
72
|
-
df = df.reindex(ts)
|
|
73
|
-
df = df.ffill()
|
|
74
|
-
df.index = pd.to_datetime(df.index)
|
|
75
|
-
return {ts.date(): row for ts, row in df.to_dict("index").items()}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def get_returns(
|
|
79
|
-
instrument_ids: list[int],
|
|
80
|
-
from_date: date,
|
|
81
|
-
to_date: date,
|
|
82
|
-
to_currency: Currency | None = None,
|
|
83
|
-
ffill_returns: bool = True,
|
|
84
|
-
) -> pd.DataFrame:
|
|
85
|
-
"""
|
|
86
|
-
Utility methods to get instrument returns for a given date range
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
from_date: date range lower bound
|
|
90
|
-
to_date: date range upper bound
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
Return a tuple of the returns and the last prices series for conveniance
|
|
94
|
-
"""
|
|
95
|
-
if to_currency:
|
|
96
|
-
fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
|
|
97
|
-
else:
|
|
98
|
-
fx_rate = Value(Decimal(1.0))
|
|
99
|
-
prices = InstrumentPrice.objects.filter(
|
|
100
|
-
instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
|
|
101
|
-
).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
|
|
102
|
-
prices_df = (
|
|
103
|
-
pd.DataFrame(
|
|
104
|
-
prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
105
|
-
columns=["instrument", "price_fx_portfolio", "date"],
|
|
106
|
-
)
|
|
107
|
-
.pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
108
|
-
.astype(float)
|
|
109
|
-
.sort_index()
|
|
110
|
-
)
|
|
111
|
-
if prices_df.empty:
|
|
112
|
-
raise InvalidAnalyticPortfolio()
|
|
113
|
-
ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
|
|
114
|
-
prices_df = prices_df.reindex(ts)
|
|
115
|
-
if ffill_returns:
|
|
116
|
-
prices_df = prices_df.ffill()
|
|
117
|
-
prices_df.index = pd.to_datetime(prices_df.index)
|
|
118
|
-
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
119
|
-
return returns.replace([np.inf, -np.inf, np.nan], 0)
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
MARKET_HOLIDAY_MAX_DURATION = 15
|
|
120
53
|
|
|
121
54
|
|
|
122
55
|
class DefaultPortfolioQueryset(QuerySet):
|
|
@@ -142,7 +75,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
142
75
|
"""
|
|
143
76
|
A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
|
|
144
77
|
"""
|
|
145
|
-
|
|
78
|
+
max_iterations: int = (
|
|
146
79
|
5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
|
|
147
80
|
)
|
|
148
81
|
remaining_portfolios = set(self)
|
|
@@ -155,7 +88,7 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
155
88
|
dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
|
|
156
89
|
portfolio=p, dependency_portfolio__in=remaining_portfolios
|
|
157
90
|
) # get dependency portfolios
|
|
158
|
-
if iterator_counter >=
|
|
91
|
+
if iterator_counter >= max_iterations or (
|
|
159
92
|
not dependency_relationships.exists() and not bool(parent_portfolios)
|
|
160
93
|
): # if not dependency portfolio or parent portfolio that remained, then we yield
|
|
161
94
|
remaining_portfolios.remove(p)
|
|
@@ -196,28 +129,32 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
|
|
|
196
129
|
|
|
197
130
|
class PortfolioPortfolioThroughModel(models.Model):
|
|
198
131
|
class Type(models.TextChoices):
|
|
199
|
-
|
|
132
|
+
LOOK_THROUGH = "LOOK_THROUGH", "Look-through"
|
|
200
133
|
MODEL = "MODEL", "Model"
|
|
201
134
|
CUSTODIAN = "CUSTODIAN", "Custodian"
|
|
135
|
+
HIERARCHICAL = "HIERARCHICAL", "Hierarchical"
|
|
202
136
|
|
|
203
137
|
portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
|
|
204
138
|
dependency_portfolio = models.ForeignKey(
|
|
205
139
|
"wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependent_through"
|
|
206
140
|
)
|
|
207
|
-
type = models.CharField(choices=Type.choices, default=Type.
|
|
141
|
+
type = models.CharField(choices=Type.choices, default=Type.LOOK_THROUGH, verbose_name="Type")
|
|
208
142
|
|
|
209
143
|
def __str__(self):
|
|
210
144
|
return f"{self.portfolio} dependant on {self.dependency_portfolio} ({self.Type[self.type].label})"
|
|
211
145
|
|
|
212
146
|
class Meta:
|
|
213
147
|
constraints = [
|
|
214
|
-
models.UniqueConstraint(
|
|
148
|
+
models.UniqueConstraint(
|
|
149
|
+
fields=["portfolio", "type"], name="unique_lookthrough", condition=Q(type="LOOK_THROUGH")
|
|
150
|
+
),
|
|
215
151
|
models.UniqueConstraint(fields=["portfolio", "type"], name="unique_model", condition=Q(type="MODEL")),
|
|
216
152
|
]
|
|
217
153
|
|
|
218
154
|
|
|
219
155
|
class Portfolio(DeleteToDisableMixin, WBModel):
|
|
220
156
|
assets: models.QuerySet[AssetPosition]
|
|
157
|
+
builder: AssetPositionBuilder
|
|
221
158
|
|
|
222
159
|
name = models.CharField(
|
|
223
160
|
max_length=255,
|
|
@@ -275,7 +212,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
275
212
|
|
|
276
213
|
is_manageable = models.BooleanField(
|
|
277
214
|
default=False,
|
|
278
|
-
help_text="True if the portfolio can be manually modified (e.g.
|
|
215
|
+
help_text="True if the portfolio can be manually modified (e.g. Order Proposal be submitted or total weight recomputed)",
|
|
279
216
|
)
|
|
280
217
|
is_tracked = models.BooleanField(
|
|
281
218
|
default=True,
|
|
@@ -303,14 +240,37 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
303
240
|
blank=True,
|
|
304
241
|
)
|
|
305
242
|
|
|
243
|
+
# OMS default parameters. Used to seed order proposal default value upon creation
|
|
244
|
+
default_order_proposal_min_order_value = models.IntegerField(
|
|
245
|
+
default=0, verbose_name="Default Order Proposal Minimum Order Value"
|
|
246
|
+
)
|
|
247
|
+
default_order_proposal_min_weighting = models.DecimalField(
|
|
248
|
+
max_digits=9,
|
|
249
|
+
decimal_places=8,
|
|
250
|
+
default=Decimal(0),
|
|
251
|
+
verbose_name="Default Order Proposal Minimum Weight",
|
|
252
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
253
|
+
)
|
|
254
|
+
default_order_proposal_total_cash_weight = models.DecimalField(
|
|
255
|
+
default=Decimal("0"),
|
|
256
|
+
decimal_places=4,
|
|
257
|
+
max_digits=5,
|
|
258
|
+
verbose_name="Default Order Proposal Total Cash Weight",
|
|
259
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
260
|
+
)
|
|
261
|
+
|
|
306
262
|
objects = DefaultPortfolioManager()
|
|
307
263
|
tracked_objects = ActiveTrackedPortfolioManager()
|
|
308
264
|
|
|
265
|
+
def __init__(self, *args, **kwargs):
|
|
266
|
+
self.builder = AssetPositionBuilder(self)
|
|
267
|
+
super().__init__(*args, **kwargs)
|
|
268
|
+
|
|
309
269
|
@property
|
|
310
270
|
def primary_portfolio(self):
|
|
311
271
|
with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
|
|
312
272
|
return PortfolioPortfolioThroughModel.objects.get(
|
|
313
|
-
portfolio=self, type=PortfolioPortfolioThroughModel.Type.
|
|
273
|
+
portfolio=self, type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
|
|
314
274
|
).dependency_portfolio
|
|
315
275
|
|
|
316
276
|
@property
|
|
@@ -329,6 +289,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
329
289
|
dependency_portfolio__is_composition=True,
|
|
330
290
|
).dependency_portfolio
|
|
331
291
|
|
|
292
|
+
@property
|
|
293
|
+
def is_model(self) -> bool:
|
|
294
|
+
return PortfolioPortfolioThroughModel.objects.filter(
|
|
295
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
296
|
+
dependency_portfolio=self,
|
|
297
|
+
).exists()
|
|
298
|
+
|
|
332
299
|
@property
|
|
333
300
|
def imported_assets(self):
|
|
334
301
|
return self.assets.filter(is_estimated=False)
|
|
@@ -340,6 +307,30 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
340
307
|
instruments.extend([i for i in Index.objects.filter(portfolios=self)])
|
|
341
308
|
return instruments
|
|
342
309
|
|
|
310
|
+
@property
|
|
311
|
+
def cash_component(self) -> Cash:
|
|
312
|
+
return Cash.objects.get_or_create(
|
|
313
|
+
currency=self.currency, defaults={"is_cash": True, "name": self.currency.title}
|
|
314
|
+
)[0]
|
|
315
|
+
|
|
316
|
+
def get_authenticated_custodian_adapter(self, **kwargs) -> BaseCustodianAdapter | None:
|
|
317
|
+
supported_instruments_for_routing = list(
|
|
318
|
+
filter(lambda o: o.order_routing_custodian_adapter, self.pms_instruments)
|
|
319
|
+
)
|
|
320
|
+
if not supported_instruments_for_routing:
|
|
321
|
+
raise ValueError("No custodian adapter for this portfolio")
|
|
322
|
+
|
|
323
|
+
pms_instrument = supported_instruments_for_routing[
|
|
324
|
+
0
|
|
325
|
+
] # for simplicity we support only one instrument per portfolio that is allowed to support order routing
|
|
326
|
+
adapter = import_from_dotted_path(pms_instrument.order_routing_custodian_adapter)(
|
|
327
|
+
isin=pms_instrument.isin, identifier=pms_instrument.identifier, **kwargs
|
|
328
|
+
)
|
|
329
|
+
adapter.authenticate()
|
|
330
|
+
if not adapter.is_valid():
|
|
331
|
+
raise ValueError("This portfolio is not valid for rebalancing")
|
|
332
|
+
return adapter
|
|
333
|
+
|
|
343
334
|
@property
|
|
344
335
|
def can_be_rebalanced(self):
|
|
345
336
|
return self.is_manageable and not self.is_lookthrough
|
|
@@ -355,9 +346,17 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
355
346
|
|
|
356
347
|
def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
|
|
357
348
|
"returns the dto representation of this portfolio at the specified date"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
349
|
+
assets = self.assets.filter(date=val_date, **extra_kwargs)
|
|
350
|
+
try:
|
|
351
|
+
last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
|
|
352
|
+
last_returns = last_returns.to_dict()
|
|
353
|
+
except ValueError:
|
|
354
|
+
last_returns = {}
|
|
355
|
+
positions = []
|
|
356
|
+
for asset in assets:
|
|
357
|
+
positions.append(asset._build_dto(daily_return=last_returns.get(asset.underlying_quote.id, Decimal("0"))))
|
|
358
|
+
|
|
359
|
+
return PortfolioDTO(positions)
|
|
361
360
|
|
|
362
361
|
def get_weights(self, val_date: date) -> dict[int, float]:
|
|
363
362
|
"""
|
|
@@ -380,7 +379,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
380
379
|
)
|
|
381
380
|
|
|
382
381
|
def get_analytic_portfolio(
|
|
383
|
-
self, val_date: date, weights: dict[int, float] | None = None, **kwargs
|
|
382
|
+
self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
|
|
384
383
|
) -> AnalyticPortfolio:
|
|
385
384
|
"""
|
|
386
385
|
Return the analytic portfolio associated with this portfolio at the given date
|
|
@@ -395,11 +394,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
395
394
|
if not weights:
|
|
396
395
|
weights = self.get_weights(val_date)
|
|
397
396
|
return_date = (val_date + BDay(1)).date()
|
|
398
|
-
returns =
|
|
399
|
-
list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
|
|
400
|
-
)
|
|
397
|
+
returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl).copy()
|
|
401
398
|
if pd.Timestamp(return_date) not in returns.index:
|
|
402
|
-
raise
|
|
399
|
+
raise ValueError()
|
|
400
|
+
returns = returns.loc[:return_date, :]
|
|
403
401
|
returns = returns.fillna(0) # not sure this is what we want
|
|
404
402
|
return AnalyticPortfolio(
|
|
405
403
|
X=returns,
|
|
@@ -430,12 +428,22 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
430
428
|
True,
|
|
431
429
|
),
|
|
432
430
|
create_notification_type(
|
|
433
|
-
"wbportfolio.portfolio.
|
|
434
|
-
"Portfolio
|
|
435
|
-
"Sends a notification when a the requested
|
|
431
|
+
"wbportfolio.portfolio.action_done",
|
|
432
|
+
"Portfolio Action finished",
|
|
433
|
+
"Sends a notification when a the requested portfolio action is done (e.g. replay, quote adjustment...)",
|
|
434
|
+
True,
|
|
435
|
+
True,
|
|
436
|
+
True,
|
|
437
|
+
is_lock=True,
|
|
438
|
+
),
|
|
439
|
+
create_notification_type(
|
|
440
|
+
"wbportfolio.portfolio.warning",
|
|
441
|
+
"PMS Warning",
|
|
442
|
+
"Sends a notification to warn portfolio manager or administrator regarding issue that needs action.",
|
|
436
443
|
True,
|
|
437
444
|
True,
|
|
438
445
|
True,
|
|
446
|
+
is_lock=True,
|
|
439
447
|
),
|
|
440
448
|
]
|
|
441
449
|
|
|
@@ -565,7 +573,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
565
573
|
val_date=val_date,
|
|
566
574
|
exclude_cash=True,
|
|
567
575
|
exclude_index=True,
|
|
568
|
-
extra_filter_parameters={"
|
|
576
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
569
577
|
**kwargs,
|
|
570
578
|
)
|
|
571
579
|
if not df.empty:
|
|
@@ -578,7 +586,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
578
586
|
val_date=val_date,
|
|
579
587
|
exclude_cash=True,
|
|
580
588
|
exclude_index=True,
|
|
581
|
-
extra_filter_parameters={"
|
|
589
|
+
extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
|
|
582
590
|
**kwargs,
|
|
583
591
|
)
|
|
584
592
|
if not df.empty:
|
|
@@ -650,7 +658,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
650
658
|
) -> pd.DataFrame:
|
|
651
659
|
qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
|
|
652
660
|
if only_equity:
|
|
653
|
-
qs = qs.filter(
|
|
661
|
+
qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
|
|
654
662
|
qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
|
|
655
663
|
df = Portfolio.get_contribution_df(
|
|
656
664
|
qs.select_related("underlying_instrument").values_list(
|
|
@@ -690,42 +698,49 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
690
698
|
if portfolio := asset.underlying_instrument.portfolio:
|
|
691
699
|
yield portfolio, asset.weighting
|
|
692
700
|
|
|
701
|
+
def get_next_rebalancing_date(self, start_date: date) -> date | None:
|
|
702
|
+
if automatic_rebalancer := getattr(self, "automatic_rebalancer", None):
|
|
703
|
+
return automatic_rebalancer.get_next_rebalancing_date(start_date)
|
|
704
|
+
|
|
705
|
+
def fix_quantization(self, val_date: date):
|
|
706
|
+
assets = self.assets.filter(date=val_date)
|
|
707
|
+
total_weighting = assets.aggregate(s=Sum("weighting"))["s"]
|
|
708
|
+
if total_weighting and (quantization_error := Decimal("1") - total_weighting):
|
|
709
|
+
cash = self.cash_component
|
|
710
|
+
try:
|
|
711
|
+
cash_pos = assets.get(underlying_quote=cash)
|
|
712
|
+
cash_pos.weighting += quantization_error
|
|
713
|
+
except AssetPosition.DoesNotExist:
|
|
714
|
+
cash_pos = AssetPosition(
|
|
715
|
+
portfolio=self,
|
|
716
|
+
underlying_quote=cash,
|
|
717
|
+
weighting=quantization_error,
|
|
718
|
+
initial_price=Decimal("1"),
|
|
719
|
+
date=val_date,
|
|
720
|
+
is_estimated=True,
|
|
721
|
+
)
|
|
722
|
+
cash_pos.save(create_underlying_quote_price_if_missing=True)
|
|
723
|
+
|
|
693
724
|
def change_at_date(
|
|
694
725
|
self,
|
|
695
726
|
val_date: date,
|
|
696
|
-
|
|
697
|
-
force_recompute_weighting: bool = False,
|
|
727
|
+
fix_quantization: bool = False,
|
|
698
728
|
evaluate_rebalancer: bool = True,
|
|
699
|
-
|
|
729
|
+
changed_portfolio: AnalyticPortfolio | None = None,
|
|
730
|
+
broadcast_changes_at_date: bool = True,
|
|
731
|
+
**kwargs,
|
|
700
732
|
):
|
|
733
|
+
if not self.is_tracked:
|
|
734
|
+
return
|
|
701
735
|
logger.info(f"change at date for {self} at {val_date}")
|
|
702
736
|
|
|
703
|
-
if
|
|
704
|
-
# We
|
|
705
|
-
|
|
706
|
-
Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
|
|
707
|
-
)
|
|
708
|
-
if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
|
|
709
|
-
total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
|
|
710
|
-
# We check if this actually necessary
|
|
711
|
-
# (i.e. if the weight is already summed to 100%, it is already normalized)
|
|
712
|
-
if (
|
|
713
|
-
not total_weighting
|
|
714
|
-
or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001)
|
|
715
|
-
or force_recompute_weighting
|
|
716
|
-
):
|
|
717
|
-
total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
|
|
718
|
-
# TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
|
|
719
|
-
for asset in qs:
|
|
720
|
-
if total_value:
|
|
721
|
-
asset.weighting = asset._total_value_fx_portfolio / total_value
|
|
722
|
-
elif total_weighting:
|
|
723
|
-
asset.weighting = asset.weighting / total_weighting
|
|
724
|
-
asset.save()
|
|
737
|
+
if fix_quantization:
|
|
738
|
+
# We assume all ptf total weight is 100% but quantization error can occur. In that case, we create a cash component and add the weight there.
|
|
739
|
+
self.fix_quantization(val_date)
|
|
725
740
|
|
|
726
741
|
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
727
742
|
self.estimate_net_asset_values(
|
|
728
|
-
(val_date + BDay(1)).date(),
|
|
743
|
+
(val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
|
|
729
744
|
) # updating weighting in t0 influence nav in t+1
|
|
730
745
|
if evaluate_rebalancer:
|
|
731
746
|
self.evaluate_rebalancing(val_date)
|
|
@@ -737,25 +752,44 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
737
752
|
if not self.initial_position_date or self.initial_position_date > val_date:
|
|
738
753
|
self.initial_position_date = val_date
|
|
739
754
|
self.save()
|
|
755
|
+
if broadcast_changes_at_date:
|
|
756
|
+
self.handle_controlling_portfolio_change_at_date(
|
|
757
|
+
val_date,
|
|
758
|
+
fix_quantization=fix_quantization,
|
|
759
|
+
changed_portfolio=changed_portfolio,
|
|
760
|
+
**kwargs,
|
|
761
|
+
)
|
|
740
762
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
763
|
+
def handle_controlling_portfolio_change_at_date(self, val_date: date, **kwargs):
|
|
764
|
+
if self.is_tracked:
|
|
765
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
766
|
+
dependency_portfolio=self,
|
|
767
|
+
type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH,
|
|
768
|
+
portfolio__is_lookthrough=True,
|
|
769
|
+
):
|
|
770
|
+
rel.portfolio.compute_lookthrough(val_date)
|
|
771
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
772
|
+
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
773
|
+
):
|
|
774
|
+
rel.portfolio.evaluate_rebalancing(val_date)
|
|
775
|
+
for dependent_portfolio in self.get_child_portfolios(val_date):
|
|
776
|
+
# dependent_portfolio.change_at_date(val_date, **kwargs)
|
|
777
|
+
dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date, **kwargs)
|
|
778
|
+
|
|
779
|
+
def get_model_portfolio_relationships(
|
|
780
|
+
self, val_date: date
|
|
781
|
+
) -> Generator[PortfolioPortfolioThroughModel, None, None]:
|
|
748
782
|
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
749
783
|
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
750
784
|
):
|
|
751
|
-
rel.portfolio.
|
|
785
|
+
if rel.portfolio.is_active_at_date(val_date):
|
|
786
|
+
yield rel
|
|
752
787
|
for dependent_portfolio in self.get_child_portfolios(val_date):
|
|
753
|
-
dependent_portfolio.
|
|
754
|
-
dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
|
|
788
|
+
yield from dependent_portfolio.get_model_portfolio_relationships(val_date)
|
|
755
789
|
|
|
756
790
|
def evaluate_rebalancing(self, val_date: date):
|
|
757
791
|
if hasattr(self, "automatic_rebalancer"):
|
|
758
|
-
# if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a
|
|
792
|
+
# if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a order proposal automatically
|
|
759
793
|
next_business_date = (val_date + BDay(1)).date()
|
|
760
794
|
if self.automatic_rebalancer.is_valid(val_date): # we evaluate the rebalancer in t0 and t+1
|
|
761
795
|
logger.info(f"Evaluate Rebalancing for {self} at {val_date}")
|
|
@@ -764,83 +798,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
764
798
|
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
765
799
|
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
766
800
|
|
|
767
|
-
def estimate_net_asset_values(self, val_date: date,
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
},
|
|
801
|
+
def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
|
|
802
|
+
effective_portfolio_date = (val_date - BDay(1)).date()
|
|
803
|
+
with suppress(ValueError):
|
|
804
|
+
if not analytic_portfolio:
|
|
805
|
+
analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
|
|
806
|
+
for instrument in self.pms_instruments:
|
|
807
|
+
# we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
|
|
808
|
+
last_price = instrument.get_latest_price(effective_portfolio_date)
|
|
809
|
+
if (
|
|
810
|
+
instrument.is_active_at_date(val_date)
|
|
811
|
+
and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
|
|
812
|
+
and last_price
|
|
813
|
+
):
|
|
814
|
+
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
815
|
+
net_asset_value_computation_method = import_from_dotted_path(
|
|
816
|
+
net_asset_value_computation_method_path
|
|
784
817
|
)
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
818
|
+
estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
|
|
819
|
+
if estimated_net_asset_value is not None:
|
|
820
|
+
InstrumentPrice.objects.update_or_create(
|
|
821
|
+
instrument=instrument,
|
|
822
|
+
date=val_date,
|
|
823
|
+
calculated=True,
|
|
824
|
+
defaults={
|
|
825
|
+
"gross_value": estimated_net_asset_value,
|
|
826
|
+
"net_value": estimated_net_asset_value,
|
|
827
|
+
},
|
|
828
|
+
)
|
|
829
|
+
if (
|
|
830
|
+
val_date == instrument.last_price_date
|
|
831
|
+
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
832
|
+
instrument.update_last_valuation_date()
|
|
833
|
+
|
|
834
|
+
def drift_weights(
|
|
835
|
+
self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
|
|
836
|
+
) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
|
|
791
837
|
logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
838
|
+
|
|
792
839
|
rebalancer = getattr(self, "automatic_rebalancer", None)
|
|
793
840
|
# Get initial weights
|
|
794
841
|
weights = self.get_weights(start_date) # initial weights
|
|
795
842
|
if not weights:
|
|
796
843
|
previous_date = self.assets.filter(date__lte=start_date).latest("date").date
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
# Get returns and prices data for the whole date range
|
|
801
|
-
instrument_ids = list(weights.keys())
|
|
802
|
-
returns = get_returns(
|
|
803
|
-
instrument_ids,
|
|
804
|
-
(start_date - BDay(3)).date(),
|
|
805
|
-
end_date,
|
|
806
|
-
to_currency=self.currency,
|
|
807
|
-
ffill_returns=True,
|
|
808
|
-
)
|
|
809
|
-
# Get raw prices to speed up asset position creation
|
|
810
|
-
prices = get_prices(instrument_ids, (start_date - BDay(3)).date(), end_date)
|
|
811
|
-
# Instantiate the position iterator with the initial weights
|
|
812
|
-
positions = AssetPositionIterator(self, prices=prices)
|
|
813
|
-
last_trade_proposal = None
|
|
844
|
+
_, weights = next(self.drift_weights(previous_date, start_date))
|
|
845
|
+
last_order_proposal = None
|
|
814
846
|
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
815
847
|
to_date = to_date_ts.date()
|
|
816
848
|
to_is_active = self.is_active_at_date(to_date)
|
|
817
849
|
logger.info(f"Processing {to_date:%Y-%m-%d}")
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
850
|
+
order_proposal = None
|
|
851
|
+
try:
|
|
852
|
+
last_returns = self.builder.returns.loc[[to_date_ts], :]
|
|
853
|
+
analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
|
|
854
|
+
drifted_weights = analytic_portfolio.get_next_weights()
|
|
855
|
+
except KeyError: # if no return for that date, we break and continue
|
|
856
|
+
break
|
|
857
|
+
try:
|
|
858
|
+
order_proposal = self.order_proposals.get(
|
|
859
|
+
trade_date=to_date, rebalancing_model__isnull=True, status="CONFIRMED"
|
|
860
|
+
)
|
|
861
|
+
except ObjectDoesNotExist:
|
|
862
|
+
if rebalancer and rebalancer.is_valid(to_date):
|
|
863
|
+
rebalancer.portfolio = self # ensure reference is the same to access cached returns
|
|
864
|
+
effective_portfolio = PortfolioDTO(
|
|
865
|
+
positions=[
|
|
866
|
+
PositionDTO(
|
|
867
|
+
date=to_date,
|
|
868
|
+
underlying_instrument=i,
|
|
869
|
+
weighting=Decimal(w),
|
|
870
|
+
daily_return=Decimal(last_returns.iloc[-1][i]),
|
|
871
|
+
)
|
|
872
|
+
for i, w in weights.items()
|
|
873
|
+
]
|
|
874
|
+
)
|
|
875
|
+
order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
|
|
876
|
+
if order_proposal:
|
|
877
|
+
last_order_proposal = order_proposal
|
|
878
|
+
if stop_at_rebalancing:
|
|
822
879
|
break
|
|
823
|
-
target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
|
|
824
880
|
next_weights = {
|
|
825
|
-
|
|
826
|
-
for
|
|
881
|
+
trade.underlying_instrument.id: float(trade._target_weight)
|
|
882
|
+
for trade in order_proposal.get_orders()
|
|
827
883
|
}
|
|
884
|
+
yield to_date, next_weights
|
|
828
885
|
else:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
positions.add(
|
|
839
|
-
(to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
|
|
840
|
-
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
841
|
-
break
|
|
886
|
+
next_weights = drifted_weights
|
|
887
|
+
if to_is_active:
|
|
888
|
+
yield to_date, next_weights
|
|
889
|
+
else:
|
|
890
|
+
yield (
|
|
891
|
+
to_date,
|
|
892
|
+
{underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
|
|
893
|
+
) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
|
|
894
|
+
break
|
|
842
895
|
weights = next_weights
|
|
843
|
-
return
|
|
896
|
+
return last_order_proposal
|
|
844
897
|
|
|
845
898
|
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
846
899
|
"""
|
|
@@ -856,10 +909,21 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
856
909
|
if (
|
|
857
910
|
not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
|
|
858
911
|
): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
|
|
859
|
-
|
|
860
|
-
self.
|
|
861
|
-
|
|
862
|
-
)
|
|
912
|
+
self.load_builder_returns(from_date, to_date)
|
|
913
|
+
for pos_date, weights in self.drift_weights(from_date, to_date):
|
|
914
|
+
self.builder.add((pos_date, weights))
|
|
915
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
916
|
+
self.builder.schedule_change_at_dates()
|
|
917
|
+
self.builder.schedule_metric_computation()
|
|
918
|
+
|
|
919
|
+
def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
|
|
920
|
+
instruments_ids = list(self.get_weights(from_date).keys())
|
|
921
|
+
for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date):
|
|
922
|
+
instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
|
|
923
|
+
self.builder.load_returns(
|
|
924
|
+
set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
|
|
925
|
+
)
|
|
926
|
+
return self.builder.returns
|
|
863
927
|
|
|
864
928
|
def get_lookthrough_positions(
|
|
865
929
|
self,
|
|
@@ -903,7 +967,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
903
967
|
except IndexError:
|
|
904
968
|
position.portfolio_created = None
|
|
905
969
|
|
|
906
|
-
|
|
970
|
+
position.path = path
|
|
907
971
|
position.initial_shares = None
|
|
908
972
|
if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
|
|
909
973
|
position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
|
|
@@ -926,7 +990,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
926
990
|
if self.is_composition:
|
|
927
991
|
assets = list(self.get_lookthrough_positions(val_date, **kwargs))
|
|
928
992
|
else:
|
|
929
|
-
assets = self.assets.filter(date=val_date)
|
|
993
|
+
assets = list(self.assets.filter(date=val_date))
|
|
930
994
|
return assets
|
|
931
995
|
|
|
932
996
|
def compute_lookthrough(self, from_date: date, to_date: date | None = None):
|
|
@@ -934,18 +998,20 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
934
998
|
raise ValueError(
|
|
935
999
|
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
936
1000
|
)
|
|
937
|
-
positions = AssetPositionIterator(self)
|
|
938
1001
|
if not to_date:
|
|
939
1002
|
to_date = from_date
|
|
940
|
-
for
|
|
941
|
-
logger.info(f"Compute Look-Through for {self} at {
|
|
1003
|
+
for val_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
1004
|
+
logger.info(f"Compute Look-Through for {self} at {val_date}")
|
|
942
1005
|
portfolio_total_asset_value = (
|
|
943
|
-
self.primary_portfolio.get_total_asset_under_management(
|
|
1006
|
+
self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
|
|
944
1007
|
)
|
|
945
|
-
|
|
946
|
-
list(self.primary_portfolio.get_lookthrough_positions(
|
|
1008
|
+
self.builder.add(
|
|
1009
|
+
list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
|
|
1010
|
+
infer_underlying_quote_price=True,
|
|
947
1011
|
)
|
|
948
|
-
self.bulk_create_positions(
|
|
1012
|
+
self.builder.bulk_create_positions(delete_leftovers=True)
|
|
1013
|
+
self.builder.schedule_change_at_dates()
|
|
1014
|
+
self.builder.schedule_metric_computation()
|
|
949
1015
|
|
|
950
1016
|
def update_preferred_classification_per_instrument(self):
|
|
951
1017
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -1002,63 +1068,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1002
1068
|
def get_representation_label_key(cls):
|
|
1003
1069
|
return "{{name}}"
|
|
1004
1070
|
|
|
1005
|
-
def bulk_create_positions(
|
|
1006
|
-
self,
|
|
1007
|
-
positions: AssetPositionIterator,
|
|
1008
|
-
delete_leftovers: bool = False,
|
|
1009
|
-
force_save: bool = False,
|
|
1010
|
-
compute_metrics: bool = True,
|
|
1011
|
-
**kwargs,
|
|
1012
|
-
):
|
|
1013
|
-
if positions:
|
|
1014
|
-
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
1015
|
-
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
1016
|
-
# change completely the trades of a portfolio model and drift it.
|
|
1017
|
-
|
|
1018
|
-
dates = positions.get_dates()
|
|
1019
|
-
self.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
1020
|
-
|
|
1021
|
-
if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
1022
|
-
leftover_positions_ids = list(
|
|
1023
|
-
self.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
1024
|
-
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
1025
|
-
positions_list = list(positions)
|
|
1026
|
-
logger.info(
|
|
1027
|
-
f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
|
|
1028
|
-
)
|
|
1029
|
-
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
1030
|
-
positions_list,
|
|
1031
|
-
update_fields=[
|
|
1032
|
-
"weighting",
|
|
1033
|
-
"initial_price",
|
|
1034
|
-
"initial_currency_fx_rate",
|
|
1035
|
-
"initial_shares",
|
|
1036
|
-
"currency_fx_rate_instrument_to_usd",
|
|
1037
|
-
"currency_fx_rate_portfolio_to_usd",
|
|
1038
|
-
"underlying_quote_price",
|
|
1039
|
-
"portfolio",
|
|
1040
|
-
"portfolio_created",
|
|
1041
|
-
"underlying_instrument",
|
|
1042
|
-
],
|
|
1043
|
-
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
1044
|
-
update_conflicts=True,
|
|
1045
|
-
batch_size=10000,
|
|
1046
|
-
)
|
|
1047
|
-
if delete_leftovers:
|
|
1048
|
-
objs_ids = list(map(lambda x: x.id, objs))
|
|
1049
|
-
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
1050
|
-
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
1051
|
-
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
1052
|
-
if compute_metrics and self.is_tracked:
|
|
1053
|
-
for val_date in dates:
|
|
1054
|
-
compute_metrics_as_task.delay(
|
|
1055
|
-
val_date,
|
|
1056
|
-
basket_id=self.id,
|
|
1057
|
-
basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id,
|
|
1058
|
-
)
|
|
1059
|
-
for update_date, changed_weights in positions.get_weights().items():
|
|
1060
|
-
self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
|
|
1061
|
-
|
|
1062
1071
|
@classmethod
|
|
1063
1072
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
1064
1073
|
if isinstance(portfolio_data, int):
|
|
@@ -1198,6 +1207,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1198
1207
|
index = Index.objects.create(name=self.name, currency=self.currency)
|
|
1199
1208
|
index.portfolios.all().delete()
|
|
1200
1209
|
InstrumentPortfolioThroughModel.objects.update_or_create(instrument=index, defaults={"portfolio": self})
|
|
1210
|
+
return index
|
|
1201
1211
|
|
|
1202
1212
|
@classmethod
|
|
1203
1213
|
def create_model_portfolio(cls, name: str, currency: Currency, with_index: bool = True):
|
|
@@ -1211,20 +1221,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1211
1221
|
return portfolio
|
|
1212
1222
|
|
|
1213
1223
|
|
|
1214
|
-
def default_estimate_net_value(
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
portfolio: Portfolio = instrument.portfolio
|
|
1218
|
-
previous_val_date = (val_date - BDay(1)).date()
|
|
1219
|
-
if not weights:
|
|
1220
|
-
weights = portfolio.get_weights(previous_val_date)
|
|
1221
|
-
# we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
|
|
1222
|
-
if weights and (last_price := instrument.get_latest_price(previous_val_date)):
|
|
1223
|
-
with suppress(
|
|
1224
|
-
IndexError, InvalidAnalyticPortfolio
|
|
1225
|
-
): # we silent any indexerror introduced by no returns for the past days
|
|
1226
|
-
analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
|
|
1227
|
-
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1224
|
+
def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
|
|
1225
|
+
with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
|
|
1226
|
+
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1228
1227
|
|
|
1229
1228
|
|
|
1230
1229
|
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
@@ -1233,7 +1232,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
|
|
|
1233
1232
|
not raw
|
|
1234
1233
|
and created
|
|
1235
1234
|
and instance.portfolio.is_lookthrough
|
|
1236
|
-
and instance.type == PortfolioPortfolioThroughModel.Type.
|
|
1235
|
+
and instance.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
|
|
1237
1236
|
):
|
|
1238
1237
|
with suppress(AssetPosition.DoesNotExist):
|
|
1239
1238
|
earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
|
|
@@ -1258,10 +1257,33 @@ def update_portfolio_after_investable_universe(*args, end_date: date | None = No
|
|
|
1258
1257
|
end_date = date.today()
|
|
1259
1258
|
end_date = (end_date + timedelta(days=1) - BDay(1)).date() # shift in case of business day
|
|
1260
1259
|
from_date = (end_date - BDay(1)).date()
|
|
1260
|
+
excluded_positions = defaultdict(list)
|
|
1261
1261
|
for portfolio in Portfolio.tracked_objects.all().to_dependency_iterator(from_date):
|
|
1262
1262
|
if not portfolio.is_lookthrough:
|
|
1263
1263
|
try:
|
|
1264
1264
|
portfolio.propagate_or_update_assets(from_date, end_date)
|
|
1265
|
+
for positions in portfolio.builder.excluded_positions.values():
|
|
1266
|
+
for pos in positions:
|
|
1267
|
+
excluded_positions[pos.underlying_quote].append(portfolio)
|
|
1268
|
+
portfolio.builder.clear()
|
|
1265
1269
|
except Exception as e:
|
|
1266
1270
|
logger.error(f"Exception while propagating portfolio assets {portfolio}: {e}")
|
|
1267
1271
|
portfolio.estimate_net_asset_values(end_date)
|
|
1272
|
+
# if there were excluded positions, we compiled a itemized list of quote per portfolio that got excluded and warn the current portfolio manager
|
|
1273
|
+
if excluded_positions:
|
|
1274
|
+
body = (
|
|
1275
|
+
"<p>While drifting the portfolios, the following quotes got excluded because of missing prices: </p><ul>"
|
|
1276
|
+
)
|
|
1277
|
+
for quote, portfolios in excluded_positions.items():
|
|
1278
|
+
body += f"<li>{quote}</li><p>Impacted portfolios: </p><ul>"
|
|
1279
|
+
for portfolio in portfolios:
|
|
1280
|
+
body += f"<li>{portfolio}</li>"
|
|
1281
|
+
body += "</ul>"
|
|
1282
|
+
body += "</ul> <p>Note: If the quote has simply changed its primary exchange, please use the adjustment tool provided. Otherwise, please contact a system administrator.</p>"
|
|
1283
|
+
for user in User.objects.filter(profile__in=PortfolioRole.portfolio_managers(), is_active=True):
|
|
1284
|
+
send_notification(
|
|
1285
|
+
code="wbportfolio.portfolio.warning",
|
|
1286
|
+
title="Positions were automatically excluded",
|
|
1287
|
+
body=body,
|
|
1288
|
+
user=user,
|
|
1289
|
+
)
|