wbportfolio 1.44.5__py2.py3-none-any.whl → 1.45.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/admin/__init__.py +1 -1
- wbportfolio/admin/asset.py +2 -1
- wbportfolio/admin/custodians.py +1 -0
- wbportfolio/admin/indexes.py +15 -0
- wbportfolio/admin/portfolio.py +12 -7
- wbportfolio/admin/portfolio_relationships.py +1 -0
- wbportfolio/admin/product_groups.py +2 -0
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/reconciliations.py +1 -0
- wbportfolio/admin/registers.py +1 -0
- wbportfolio/admin/roles.py +1 -0
- wbportfolio/admin/transactions/__init__.py +1 -0
- wbportfolio/admin/transactions/claim.py +1 -0
- wbportfolio/admin/transactions/dividends.py +1 -0
- wbportfolio/admin/transactions/fees.py +1 -0
- wbportfolio/admin/transactions/rebalancing.py +26 -0
- wbportfolio/admin/transactions/trades.py +4 -3
- wbportfolio/admin/transactions/transactions.py +1 -0
- wbportfolio/analysis/claims.py +2 -1
- wbportfolio/contrib/company_portfolio/models.py +3 -6
- wbportfolio/contrib/company_portfolio/tests/conftest.py +0 -12
- wbportfolio/contrib/company_portfolio/tests/test_models.py +1 -0
- wbportfolio/defaults/fees/default.py +1 -0
- wbportfolio/factories/__init__.py +1 -7
- wbportfolio/factories/adjustments.py +1 -0
- wbportfolio/factories/assets.py +13 -7
- wbportfolio/factories/claim.py +1 -0
- wbportfolio/factories/custodians.py +1 -0
- wbportfolio/factories/dividends.py +1 -0
- wbportfolio/factories/fees.py +1 -0
- wbportfolio/factories/indexes.py +1 -0
- wbportfolio/factories/portfolio_cash_flow.py +1 -0
- wbportfolio/factories/portfolio_cash_targets.py +1 -0
- wbportfolio/factories/portfolio_swing_pricings.py +1 -0
- wbportfolio/factories/portfolios.py +3 -0
- wbportfolio/factories/product_groups.py +1 -0
- wbportfolio/factories/products.py +1 -0
- wbportfolio/factories/rebalancing.py +23 -0
- wbportfolio/factories/reconciliations.py +1 -0
- wbportfolio/factories/roles.py +1 -0
- wbportfolio/factories/trades.py +1 -0
- wbportfolio/factories/transactions.py +1 -0
- wbportfolio/fdm/tasks.py +1 -0
- wbportfolio/filters/__init__.py +1 -1
- wbportfolio/filters/assets.py +8 -9
- wbportfolio/filters/assets_and_net_new_money_progression.py +1 -0
- wbportfolio/filters/custodians.py +1 -0
- wbportfolio/filters/esg.py +1 -0
- wbportfolio/filters/performances.py +7 -6
- wbportfolio/filters/portfolios.py +21 -1
- wbportfolio/filters/positions.py +1 -0
- wbportfolio/filters/products.py +1 -0
- wbportfolio/filters/roles.py +1 -0
- wbportfolio/filters/signals.py +1 -0
- wbportfolio/filters/transactions/claim.py +1 -0
- wbportfolio/filters/transactions/fees.py +1 -0
- wbportfolio/filters/transactions/trades.py +2 -1
- wbportfolio/filters/transactions/transactions.py +1 -0
- wbportfolio/import_export/backends/ubs/mixin.py +1 -0
- wbportfolio/import_export/backends/wbfdm/adjustment.py +1 -0
- wbportfolio/import_export/handlers/asset_position.py +11 -13
- wbportfolio/import_export/handlers/fees.py +1 -0
- wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -0
- wbportfolio/import_export/handlers/trade.py +1 -0
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/jpmorgan/fees.py +1 -0
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +5 -4
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +1 -0
- wbportfolio/import_export/parsers/leonteq/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/leonteq/equity.py +13 -12
- wbportfolio/import_export/parsers/leonteq/fees.py +1 -0
- wbportfolio/import_export/parsers/leonteq/trade.py +1 -0
- wbportfolio/import_export/parsers/leonteq/valuation.py +1 -0
- wbportfolio/import_export/parsers/natixis/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/natixis/d1_customer_trade.py +1 -0
- wbportfolio/import_export/parsers/natixis/d1_equity.py +3 -2
- wbportfolio/import_export/parsers/natixis/d1_fees.py +1 -0
- wbportfolio/import_export/parsers/natixis/d1_trade.py +1 -0
- wbportfolio/import_export/parsers/natixis/d1_valuation.py +1 -0
- wbportfolio/import_export/parsers/natixis/equity.py +5 -5
- wbportfolio/import_export/parsers/natixis/trade.py +1 -0
- wbportfolio/import_export/parsers/natixis/utils.py +8 -7
- wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -0
- wbportfolio/import_export/parsers/sg_lux/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +2 -1
- wbportfolio/import_export/parsers/sg_lux/customer_trade_slk.py +2 -1
- wbportfolio/import_export/parsers/sg_lux/customer_trade_without_pw.py +1 -0
- wbportfolio/import_export/parsers/sg_lux/equity.py +7 -8
- wbportfolio/import_export/parsers/sg_lux/portfolio_cash_flow.py +1 -0
- wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -0
- wbportfolio/import_export/parsers/sg_lux/registers.py +2 -1
- wbportfolio/import_export/parsers/societe_generale/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/societe_generale/strategy.py +8 -9
- wbportfolio/import_export/parsers/societe_generale/valuation.py +1 -0
- wbportfolio/import_export/parsers/tellco/equity.py +5 -4
- wbportfolio/import_export/parsers/ubs/api/asset_position.py +15 -14
- wbportfolio/import_export/parsers/ubs/api/fees.py +1 -0
- wbportfolio/import_export/parsers/ubs/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/ubs/equity.py +3 -2
- wbportfolio/import_export/parsers/ubs/historical_customer_trade.py +1 -0
- wbportfolio/import_export/parsers/ubs/valuation.py +1 -0
- wbportfolio/import_export/parsers/vontobel/asset_position.py +19 -19
- wbportfolio/import_export/parsers/vontobel/customer_trade.py +1 -0
- wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +1 -0
- wbportfolio/import_export/parsers/vontobel/management_fees.py +1 -0
- wbportfolio/import_export/parsers/vontobel/performance_fees.py +1 -0
- wbportfolio/import_export/parsers/vontobel/trade.py +1 -0
- wbportfolio/import_export/parsers/vontobel/valuation_api.py +23 -0
- wbportfolio/import_export/resources/assets.py +4 -3
- wbportfolio/import_export/resources/trades.py +1 -0
- wbportfolio/metric/backends/base.py +1 -0
- wbportfolio/metric/backends/portfolio_base.py +1 -0
- wbportfolio/metric/backends/portfolio_esg.py +1 -0
- wbportfolio/metric/tests/test_portfolio_base.py +1 -0
- wbportfolio/migrations/0052_remove_cash_instrument_ptr_and_more.py +1 -131
- wbportfolio/migrations/0067_assetposition_unique_asset_position.py +1 -1
- wbportfolio/migrations/0070_remove_assetposition_unique_asset_position_and_more.py +1 -1
- wbportfolio/migrations/0073_remove_product_price_computation_and_more.py +407 -0
- wbportfolio/models/__init__.py +0 -5
- wbportfolio/models/adjustments.py +8 -2
- wbportfolio/models/asset.py +117 -98
- wbportfolio/models/graphs/portfolio.py +161 -0
- wbportfolio/models/graphs/utils.py +83 -0
- wbportfolio/models/indexes.py +2 -13
- wbportfolio/models/mixins/instruments.py +28 -8
- wbportfolio/models/portfolio.py +538 -332
- wbportfolio/models/portfolio_cash_flow.py +1 -0
- wbportfolio/models/portfolio_relationship.py +6 -2
- wbportfolio/models/product_groups.py +3 -2
- wbportfolio/models/products.py +3 -17
- wbportfolio/models/reconciliations/account_reconciliation_lines.py +1 -0
- wbportfolio/models/reconciliations/account_reconciliations.py +1 -0
- wbportfolio/models/registers.py +1 -0
- wbportfolio/models/transactions/__init__.py +1 -0
- wbportfolio/models/transactions/claim.py +8 -8
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/fees.py +1 -0
- wbportfolio/models/transactions/rebalancing.py +153 -0
- wbportfolio/models/transactions/trade_proposals.py +153 -155
- wbportfolio/models/transactions/trades.py +48 -40
- wbportfolio/models/transactions/transactions.py +6 -12
- wbportfolio/models/utils.py +1 -0
- wbportfolio/pms/analytics/__init__.py +0 -0
- wbportfolio/pms/analytics/portfolio.py +28 -0
- wbportfolio/pms/trading/handler.py +13 -16
- wbportfolio/pms/typing.py +13 -29
- wbportfolio/rebalancing/__init__.py +0 -0
- wbportfolio/rebalancing/base.py +16 -0
- wbportfolio/rebalancing/decorators.py +17 -0
- wbportfolio/rebalancing/models/__init__.py +3 -0
- wbportfolio/rebalancing/models/composite.py +31 -0
- wbportfolio/rebalancing/models/equally_weighted.py +21 -0
- wbportfolio/rebalancing/models/model_portfolio.py +35 -0
- wbportfolio/reports/monthly_position_report.py +1 -1
- wbportfolio/risk_management/backends/accounts.py +7 -6
- wbportfolio/risk_management/backends/controversy_portfolio.py +1 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +1 -0
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -0
- wbportfolio/risk_management/backends/liquidity_risk.py +1 -0
- wbportfolio/risk_management/backends/liquidity_stress_instrument.py +1 -0
- wbportfolio/risk_management/backends/mixins.py +1 -0
- wbportfolio/risk_management/backends/product_integrity.py +6 -1
- wbportfolio/risk_management/backends/stop_loss_instrument.py +1 -0
- wbportfolio/risk_management/backends/stop_loss_portfolio.py +1 -0
- wbportfolio/risk_management/backends/ucits_portfolio.py +12 -5
- wbportfolio/risk_management/tests/test_accounts.py +1 -0
- wbportfolio/risk_management/tests/test_controversy_portfolio.py +1 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -0
- wbportfolio/risk_management/tests/test_instrument_list_portfolio.py +1 -0
- wbportfolio/risk_management/tests/test_liquidity_risk.py +1 -0
- wbportfolio/risk_management/tests/test_product_integrity.py +1 -0
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +1 -0
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -0
- wbportfolio/risk_management/tests/test_ucits_portfolio.py +2 -1
- wbportfolio/serializers/__init__.py +5 -5
- wbportfolio/serializers/adjustments.py +1 -0
- wbportfolio/serializers/assets.py +18 -19
- wbportfolio/serializers/custodians.py +1 -0
- wbportfolio/serializers/portfolio_cash_flow.py +1 -0
- wbportfolio/serializers/portfolio_cash_targets.py +1 -0
- wbportfolio/serializers/portfolio_relationship.py +1 -0
- wbportfolio/serializers/portfolio_swing_pricing.py +1 -0
- wbportfolio/serializers/portfolios.py +61 -40
- wbportfolio/serializers/positions.py +1 -0
- wbportfolio/serializers/product_group.py +1 -0
- wbportfolio/serializers/products.py +4 -7
- wbportfolio/serializers/rebalancing.py +57 -0
- wbportfolio/serializers/reconciliations.py +2 -1
- wbportfolio/serializers/registers.py +1 -0
- wbportfolio/serializers/roles.py +1 -0
- wbportfolio/serializers/signals.py +10 -15
- wbportfolio/serializers/transactions/__init__.py +1 -1
- wbportfolio/serializers/transactions/claim.py +1 -0
- wbportfolio/serializers/transactions/fees.py +1 -0
- wbportfolio/serializers/transactions/trade_proposals.py +85 -0
- wbportfolio/serializers/transactions/trades.py +9 -51
- wbportfolio/serializers/transactions/transactions.py +4 -3
- wbportfolio/tasks.py +1 -78
- wbportfolio/tests/conftest.py +6 -13
- wbportfolio/tests/models/test_account_reconciliation.py +2 -0
- wbportfolio/tests/models/test_assets.py +27 -19
- wbportfolio/tests/models/test_customer_trades.py +1 -0
- wbportfolio/tests/models/test_imports.py +5 -1
- wbportfolio/tests/models/test_merge.py +5 -4
- wbportfolio/tests/models/test_portfolio_cash_flow.py +8 -6
- wbportfolio/tests/models/test_portfolios.py +619 -154
- wbportfolio/tests/models/test_product_groups.py +1 -0
- wbportfolio/tests/models/test_products.py +6 -3
- wbportfolio/tests/models/test_roles.py +1 -0
- wbportfolio/tests/models/test_splits.py +1 -0
- wbportfolio/tests/models/transactions/test_claim.py +1 -0
- wbportfolio/tests/models/transactions/test_fees.py +1 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +81 -0
- wbportfolio/tests/models/transactions/test_trades.py +1 -0
- wbportfolio/tests/models/utils.py +1 -0
- wbportfolio/tests/pms/__init__.py +0 -0
- wbportfolio/tests/pms/test_analytics.py +35 -0
- wbportfolio/tests/rebalancing/__init__.py +0 -0
- wbportfolio/tests/rebalancing/test_models.py +127 -0
- wbportfolio/tests/serializers/test_claims.py +1 -0
- wbportfolio/tests/signals.py +1 -7
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/tests/viewsets/test_assets.py +1 -0
- wbportfolio/tests/viewsets/test_performances.py +1 -0
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/tests/viewsets/transactions/test_claims.py +1 -0
- wbportfolio/urls.py +26 -12
- wbportfolio/viewsets/__init__.py +2 -5
- wbportfolio/viewsets/adjustments.py +1 -0
- wbportfolio/viewsets/assets.py +62 -51
- wbportfolio/viewsets/assets_and_net_new_money_progression.py +1 -0
- wbportfolio/viewsets/charts/assets.py +3 -1
- wbportfolio/viewsets/configs/buttons/__init__.py +1 -1
- wbportfolio/viewsets/configs/buttons/assets.py +1 -0
- wbportfolio/viewsets/configs/buttons/custodians.py +1 -0
- wbportfolio/viewsets/configs/buttons/mixins.py +1 -20
- wbportfolio/viewsets/configs/buttons/portfolios.py +90 -76
- wbportfolio/viewsets/configs/buttons/signals.py +1 -0
- wbportfolio/viewsets/configs/buttons/trades.py +1 -0
- wbportfolio/viewsets/configs/display/__init__.py +2 -1
- wbportfolio/viewsets/configs/display/adjustments.py +1 -0
- wbportfolio/viewsets/configs/display/assets.py +7 -6
- wbportfolio/viewsets/configs/display/claim.py +1 -0
- wbportfolio/viewsets/configs/display/portfolios.py +127 -79
- wbportfolio/viewsets/configs/display/product_performance.py +1 -0
- wbportfolio/viewsets/configs/display/rebalancing.py +27 -0
- wbportfolio/viewsets/configs/display/trade_proposals.py +7 -4
- wbportfolio/viewsets/configs/display/trades.py +75 -42
- wbportfolio/viewsets/configs/endpoints/__init__.py +3 -1
- wbportfolio/viewsets/configs/endpoints/assets.py +12 -0
- wbportfolio/viewsets/configs/endpoints/claim.py +1 -0
- wbportfolio/viewsets/configs/endpoints/portfolios.py +23 -7
- wbportfolio/viewsets/configs/endpoints/rebalancing.py +6 -0
- wbportfolio/viewsets/configs/endpoints/reconciliations.py +1 -0
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +1 -0
- wbportfolio/viewsets/configs/endpoints/trades.py +1 -0
- wbportfolio/viewsets/configs/menu/adjustments.py +1 -0
- wbportfolio/viewsets/configs/menu/assets.py +1 -0
- wbportfolio/viewsets/configs/menu/fees.py +1 -0
- wbportfolio/viewsets/configs/menu/portfolio_cash_flow.py +1 -0
- wbportfolio/viewsets/configs/menu/portfolios.py +4 -2
- wbportfolio/viewsets/configs/menu/positions.py +1 -0
- wbportfolio/viewsets/configs/menu/roles.py +1 -0
- wbportfolio/viewsets/configs/menu/transactions.py +1 -0
- wbportfolio/viewsets/configs/previews/portfolios.py +1 -6
- wbportfolio/viewsets/configs/titles/__init__.py +1 -1
- wbportfolio/viewsets/configs/titles/assets.py +1 -0
- wbportfolio/viewsets/configs/titles/fees.py +1 -0
- wbportfolio/viewsets/configs/titles/instrument_prices.py +1 -0
- wbportfolio/viewsets/configs/titles/portfolios.py +13 -11
- wbportfolio/viewsets/configs/titles/roles.py +1 -0
- wbportfolio/viewsets/configs/titles/trades.py +1 -0
- wbportfolio/viewsets/configs/titles/transactions.py +1 -0
- wbportfolio/viewsets/custodians.py +1 -0
- wbportfolio/viewsets/esg.py +1 -0
- wbportfolio/viewsets/mixins.py +1 -0
- wbportfolio/viewsets/portfolio_cash_flow.py +1 -0
- wbportfolio/viewsets/portfolio_cash_targets.py +1 -0
- wbportfolio/viewsets/portfolio_relationship.py +1 -0
- wbportfolio/viewsets/portfolio_swing_pricing.py +1 -0
- wbportfolio/viewsets/portfolios.py +228 -61
- wbportfolio/viewsets/positions.py +3 -2
- wbportfolio/viewsets/product_groups.py +1 -0
- wbportfolio/viewsets/product_performance.py +1 -0
- wbportfolio/viewsets/products.py +1 -0
- wbportfolio/viewsets/reconciliations.py +1 -0
- wbportfolio/viewsets/registers.py +1 -0
- wbportfolio/viewsets/roles.py +1 -0
- wbportfolio/viewsets/signals.py +1 -0
- wbportfolio/viewsets/transactions/__init__.py +1 -0
- wbportfolio/viewsets/transactions/claim.py +2 -1
- wbportfolio/viewsets/transactions/fees.py +1 -0
- wbportfolio/viewsets/transactions/mixins.py +1 -0
- wbportfolio/viewsets/transactions/rebalancing.py +31 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +25 -5
- wbportfolio/viewsets/transactions/trades.py +16 -9
- wbportfolio/viewsets/transactions/transactions.py +1 -0
- {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.1.dist-info}/METADATA +4 -1
- wbportfolio-1.45.1.dist-info/RECORD +521 -0
- wbportfolio/admin/synchronization/__init__.py +0 -2
- wbportfolio/admin/synchronization/admin.py +0 -114
- wbportfolio/admin/synchronization/portfolio_synchronization.py +0 -18
- wbportfolio/admin/synchronization/price_computation.py +0 -21
- wbportfolio/defaults/portfolio/default_rebalancing.py +0 -45
- wbportfolio/factories/pytest_utils.py +0 -121
- wbportfolio/factories/synchronization.py +0 -40
- wbportfolio/models/synchronization/__init__.py +0 -3
- wbportfolio/models/synchronization/portfolio_synchronization.py +0 -292
- wbportfolio/models/synchronization/price_computation.py +0 -200
- wbportfolio/models/synchronization/synchronization.py +0 -188
- wbportfolio/serializers/synchronization.py +0 -18
- wbportfolio/tests/models/test_synchronization.py +0 -617
- wbportfolio/viewsets/synchronization.py +0 -25
- wbportfolio-1.44.5.dist-info/RECORD +0 -508
- /wbportfolio/{defaults/portfolio → models/graphs}/__init__.py +0 -0
- {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.1.dist-info}/WHEEL +0 -0
- {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.1.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/portfolio.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
from contextlib import suppress
|
|
2
|
-
from datetime import date,
|
|
3
|
+
from datetime import date, timedelta
|
|
3
4
|
from decimal import Decimal
|
|
4
5
|
from math import isclose
|
|
5
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Iterable
|
|
6
7
|
|
|
7
8
|
import numpy as np
|
|
8
9
|
import pandas as pd
|
|
9
10
|
from celery import shared_task
|
|
11
|
+
from django.contrib.contenttypes.models import ContentType
|
|
10
12
|
from django.contrib.postgres.fields import DateRangeField
|
|
11
13
|
from django.db import models
|
|
12
14
|
from django.db.models import (
|
|
@@ -23,14 +25,20 @@ from django.db.models import (
|
|
|
23
25
|
)
|
|
24
26
|
from django.db.models.signals import post_save
|
|
25
27
|
from django.dispatch import receiver
|
|
28
|
+
from django.utils import timezone
|
|
29
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
26
30
|
from psycopg.types.range import DateRange
|
|
27
|
-
from
|
|
31
|
+
from skfolio.preprocessing import prices_to_returns
|
|
32
|
+
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
28
33
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
29
34
|
from wbcore.models import WBModel
|
|
35
|
+
from wbcore.utils.importlib import import_from_dotted_path
|
|
30
36
|
from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
|
|
31
|
-
from wbfdm.contrib.metric.
|
|
32
|
-
from wbfdm.models import Instrument
|
|
37
|
+
from wbfdm.contrib.metric.tasks import compute_metrics_as_task
|
|
38
|
+
from wbfdm.models import Instrument, InstrumentType
|
|
33
39
|
from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
40
|
+
from wbfdm.signals import investable_universe_updated
|
|
41
|
+
|
|
34
42
|
from wbportfolio.models.asset import AssetPosition
|
|
35
43
|
from wbportfolio.models.indexes import Index
|
|
36
44
|
from wbportfolio.models.portfolio_relationship import (
|
|
@@ -38,9 +46,12 @@ from wbportfolio.models.portfolio_relationship import (
|
|
|
38
46
|
PortfolioInstrumentPreferredClassificationThroughModel,
|
|
39
47
|
)
|
|
40
48
|
from wbportfolio.models.products import Product
|
|
49
|
+
from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
41
50
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
42
51
|
|
|
43
|
-
from .
|
|
52
|
+
from . import ProductGroup
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("pms")
|
|
44
55
|
|
|
45
56
|
|
|
46
57
|
class DefaultPortfolioQueryset(QuerySet):
|
|
@@ -48,7 +59,44 @@ class DefaultPortfolioQueryset(QuerySet):
|
|
|
48
59
|
"""
|
|
49
60
|
Filter the queryset to get only portfolio invested at the given date
|
|
50
61
|
"""
|
|
51
|
-
return self.filter(
|
|
62
|
+
return self.filter(
|
|
63
|
+
(Q(invested_timespan__startswith__lte=val_date) | Q(invested_timespan__startswith__isnull=True))
|
|
64
|
+
& (Q(invested_timespan__endswith__gt=val_date) | Q(invested_timespan__endswith__isnull=True))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_dependency_iterator(self, val_date: date) -> Iterable["Portfolio"]:
|
|
68
|
+
"""
|
|
69
|
+
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.
|
|
70
|
+
"""
|
|
71
|
+
MAX_ITERATIONS: int = (
|
|
72
|
+
5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
|
|
73
|
+
)
|
|
74
|
+
remaining_portfolios = set(self)
|
|
75
|
+
|
|
76
|
+
def _iterator(p, iterator_counter=0):
|
|
77
|
+
iterator_counter += 1
|
|
78
|
+
parent_portfolios = remaining_portfolios & set(
|
|
79
|
+
map(lambda o: o[0], p.get_parent_portfolios(val_date))
|
|
80
|
+
) # get composition parent portfolios
|
|
81
|
+
dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
|
|
82
|
+
portfolio=p, dependency_portfolio__in=remaining_portfolios
|
|
83
|
+
) # get dependency portfolios
|
|
84
|
+
if iterator_counter >= MAX_ITERATIONS or (
|
|
85
|
+
not dependency_relationships.exists() and not bool(parent_portfolios)
|
|
86
|
+
): # if not dependency portfolio or parent portfolio that remained, then we yield
|
|
87
|
+
remaining_portfolios.remove(p)
|
|
88
|
+
yield p
|
|
89
|
+
else:
|
|
90
|
+
# otherwise, we iterate of the dependency portfolio first
|
|
91
|
+
deps_portfolios = parent_portfolios.union(
|
|
92
|
+
set([r.dependency_portfolio for r in dependency_relationships])
|
|
93
|
+
)
|
|
94
|
+
for deps_p in deps_portfolios:
|
|
95
|
+
yield from _iterator(deps_p, iterator_counter=iterator_counter)
|
|
96
|
+
|
|
97
|
+
while len(remaining_portfolios) > 0:
|
|
98
|
+
portfolio = next(iter(remaining_portfolios))
|
|
99
|
+
yield from _iterator(portfolio)
|
|
52
100
|
|
|
53
101
|
|
|
54
102
|
class DefaultPortfolioManager(ActiveObjectManager):
|
|
@@ -73,8 +121,6 @@ class PortfolioPortfolioThroughModel(models.Model):
|
|
|
73
121
|
class Type(models.TextChoices):
|
|
74
122
|
PRIMARY = "PRIMARY", "Primary"
|
|
75
123
|
MODEL = "MODEL", "Model"
|
|
76
|
-
BENCHMARK = "BENCHMARK", "Benchmark"
|
|
77
|
-
INDEX = "INDEX", "Index"
|
|
78
124
|
CUSTODIAN = "CUSTODIAN", "Custodian"
|
|
79
125
|
|
|
80
126
|
portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
|
|
@@ -83,6 +129,9 @@ class PortfolioPortfolioThroughModel(models.Model):
|
|
|
83
129
|
)
|
|
84
130
|
type = models.CharField(choices=Type.choices, default=Type.PRIMARY, verbose_name="Type")
|
|
85
131
|
|
|
132
|
+
def __str__(self):
|
|
133
|
+
return f"{self.portfolio} dependant on {self.dependency_portfolio} ({self.Type[self.type].label})"
|
|
134
|
+
|
|
86
135
|
class Meta:
|
|
87
136
|
constraints = [
|
|
88
137
|
models.UniqueConstraint(fields=["portfolio", "type"], name="unique_primary", condition=Q(type="PRIMARY")),
|
|
@@ -126,14 +175,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
126
175
|
verbose_name="The portfolios this portfolio depends on",
|
|
127
176
|
)
|
|
128
177
|
|
|
129
|
-
portfolio_synchronization = models.ForeignKey(
|
|
130
|
-
"wbportfolio.PortfolioSynchronization",
|
|
131
|
-
null=True,
|
|
132
|
-
blank=True,
|
|
133
|
-
on_delete=models.SET_NULL,
|
|
134
|
-
related_name="portfolios",
|
|
135
|
-
verbose_name="Portfolio Synchronization Method",
|
|
136
|
-
)
|
|
137
178
|
preferred_instrument_classifications = models.ManyToManyField(
|
|
138
179
|
"wbfdm.Instrument",
|
|
139
180
|
limit_choices_to=(models.Q(instrument_type__is_classifiable=True) & models.Q(level=0)),
|
|
@@ -161,20 +202,27 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
161
202
|
)
|
|
162
203
|
is_tracked = models.BooleanField(
|
|
163
204
|
default=True,
|
|
164
|
-
help_text="True if the internal updating mechanism (e.g.,
|
|
205
|
+
help_text="True if the internal updating mechanism (e.g., Next weights or Look-Through computation, rebalancing etc...) needs to apply to this portfolio",
|
|
165
206
|
)
|
|
166
207
|
only_weighting = models.BooleanField(
|
|
167
208
|
default=False,
|
|
168
209
|
help_text="Indicates that this portfolio is only utilizing weights and disregards shares, e.g. a model portfolio",
|
|
169
210
|
)
|
|
170
|
-
|
|
171
|
-
|
|
211
|
+
is_lookthrough = models.BooleanField(
|
|
212
|
+
default=False,
|
|
213
|
+
help_text="Indicates that this portfolio is a look-through portfolio",
|
|
214
|
+
)
|
|
215
|
+
is_composition = models.BooleanField(
|
|
216
|
+
default=False, help_text="If true, this portfolio is a composition of other portfolio"
|
|
217
|
+
)
|
|
218
|
+
updated_at = models.DateTimeField(blank=True, null=True, verbose_name="Updated At")
|
|
172
219
|
bank_accounts = models.ManyToManyField(
|
|
173
220
|
to="directory.BankingContact",
|
|
174
221
|
related_name="wbportfolio_portfolios",
|
|
175
222
|
through="wbportfolio.PortfolioBankAccountThroughModel",
|
|
176
223
|
blank=True,
|
|
177
224
|
)
|
|
225
|
+
|
|
178
226
|
objects = DefaultPortfolioManager()
|
|
179
227
|
tracked_objects = ActiveTrackedPortfolioManager()
|
|
180
228
|
|
|
@@ -193,16 +241,28 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
193
241
|
).dependency_portfolio
|
|
194
242
|
|
|
195
243
|
@property
|
|
196
|
-
def
|
|
244
|
+
def composition_portfolio(self):
|
|
197
245
|
with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
|
|
198
246
|
return PortfolioPortfolioThroughModel.objects.get(
|
|
199
|
-
portfolio=self,
|
|
247
|
+
portfolio=self,
|
|
248
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
249
|
+
dependency_portfolio__is_composition=True,
|
|
200
250
|
).dependency_portfolio
|
|
201
251
|
|
|
202
252
|
@property
|
|
203
253
|
def imported_assets(self):
|
|
204
254
|
return self.assets.filter(is_estimated=False)
|
|
205
255
|
|
|
256
|
+
@property
|
|
257
|
+
def pms_instruments(self):
|
|
258
|
+
yield from Product.objects.filter(portfolios=self)
|
|
259
|
+
yield from ProductGroup.objects.filter(portfolios=self)
|
|
260
|
+
yield from Index.objects.filter(portfolios=self)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def can_be_rebalanced(self):
|
|
264
|
+
return self.is_tracked and self.is_manageable and not self.is_lookthrough
|
|
265
|
+
|
|
206
266
|
def delete(self, **kwargs):
|
|
207
267
|
super().delete(**kwargs)
|
|
208
268
|
# We check if for all linked instruments, this portfolio was the last active one (if yes, we disable the instrument)
|
|
@@ -212,10 +272,57 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
212
272
|
instrument.delisted_date = date.today() - timedelta(days=1)
|
|
213
273
|
instrument.save()
|
|
214
274
|
|
|
215
|
-
def _build_dto(self, val_date: date, **
|
|
275
|
+
def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
|
|
216
276
|
"returns the dto representation of this portfolio at the specified date"
|
|
217
277
|
return PortfolioDTO(
|
|
218
|
-
tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **
|
|
278
|
+
tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_kwargs)]),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def get_weights(self, val_date: date) -> dict[int, float]:
|
|
282
|
+
"""
|
|
283
|
+
A convenience utility method to returns the portfolio weights for this portfolio as a dictionary (instrument id as key and weights as value)
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
val_date: The date at which to return the weights for this portfolio
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
A dictionary containing the weights for this portfolio
|
|
290
|
+
"""
|
|
291
|
+
return dict(
|
|
292
|
+
map(
|
|
293
|
+
lambda r: (r[0], float(r[1])),
|
|
294
|
+
self.assets.filter(date=val_date)
|
|
295
|
+
.values("underlying_quote")
|
|
296
|
+
.annotate(sum_weight=Sum("weighting"))
|
|
297
|
+
.values_list("underlying_quote", "sum_weight"),
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def get_analytic_portfolio(self, val_date: date, with_previous_weights: bool = False) -> AnalyticPortfolio:
|
|
302
|
+
"""
|
|
303
|
+
Return the analytic portfolio associated with this portfolio at the given date
|
|
304
|
+
|
|
305
|
+
the analytic portfolio inherit from SKFolio Portfolio and can be used to access all this library methods
|
|
306
|
+
Args:
|
|
307
|
+
val_date: the date to calculate the portfolio for
|
|
308
|
+
with_previous_weights: If true, excludes the previous weights into the analytic portfolio (might be necessary for some metrics)
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The instantiated analytic portfolio
|
|
312
|
+
"""
|
|
313
|
+
weights = self.get_weights(val_date)
|
|
314
|
+
instrument_ids = weights.keys()
|
|
315
|
+
previous_weights = None
|
|
316
|
+
if with_previous_weights:
|
|
317
|
+
if previous_date := self.get_latest_asset_position_date(val_date - timedelta(days=1)):
|
|
318
|
+
previous_weights = self.get_weights(previous_date)
|
|
319
|
+
instrument_ids = previous_weights.keys()
|
|
320
|
+
returns = self.get_returns(instrument_ids, (val_date - BDay(3)).date(), val_date)[0]
|
|
321
|
+
returns = returns.fillna(0) # not sure this is what we want
|
|
322
|
+
return AnalyticPortfolio(
|
|
323
|
+
X=returns,
|
|
324
|
+
weights=weights,
|
|
325
|
+
previous_weights=previous_weights,
|
|
219
326
|
)
|
|
220
327
|
|
|
221
328
|
def is_invested_at_date(self, val_date: date) -> bool:
|
|
@@ -243,22 +350,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
243
350
|
),
|
|
244
351
|
]
|
|
245
352
|
|
|
246
|
-
@classmethod
|
|
247
|
-
def create_model_portfolio(cls, name, currency, portfolio_synchronization=None, index_parameters=dict()):
|
|
248
|
-
portfolio = cls.objects.create(
|
|
249
|
-
is_manageable=True,
|
|
250
|
-
name=name,
|
|
251
|
-
currency=currency,
|
|
252
|
-
portfolio_synchronization=portfolio_synchronization,
|
|
253
|
-
)
|
|
254
|
-
if index_parameters:
|
|
255
|
-
index = Index.objects.create(name=name, currency=currency, **index_parameters)
|
|
256
|
-
index.portfolios.all().delete()
|
|
257
|
-
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
258
|
-
instrument=index, defaults={"portfolio": portfolio}
|
|
259
|
-
)
|
|
260
|
-
return portfolio
|
|
261
|
-
|
|
262
353
|
def is_active_at_date(self, val_date: date) -> bool:
|
|
263
354
|
"""
|
|
264
355
|
Return if the base instrument has a total aum greater than 0
|
|
@@ -271,7 +362,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
271
362
|
)
|
|
272
363
|
return active_portfolio
|
|
273
364
|
|
|
274
|
-
def
|
|
365
|
+
def get_total_asset_value(self, val_date: date) -> Decimal:
|
|
275
366
|
"""
|
|
276
367
|
Return the total asset under management of the portfolio at the specified valuation date
|
|
277
368
|
Args:
|
|
@@ -281,7 +372,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
281
372
|
"""
|
|
282
373
|
return self.assets.filter(date=val_date).aggregate(s=Sum("total_value_fx_portfolio"))["s"] or Decimal(0.0)
|
|
283
374
|
|
|
284
|
-
def
|
|
375
|
+
def get_total_asset_under_management(self, val_date):
|
|
285
376
|
from wbportfolio.models.transactions.trades import Trade
|
|
286
377
|
|
|
287
378
|
trades = Trade.valid_customer_trade_objects.filter(portfolio=self, transaction_date__lte=val_date)
|
|
@@ -330,7 +421,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
330
421
|
def get_holding(self, val_date, exclude_cash=True, exclude_index=True):
|
|
331
422
|
qs = self._get_assets(with_cash=not exclude_cash).filter(date=val_date, weighting__gt=0)
|
|
332
423
|
if exclude_index:
|
|
333
|
-
qs = qs.exclude(
|
|
424
|
+
qs = qs.exclude(underlying_instrument__instrument_type=InstrumentType.INDEX)
|
|
334
425
|
return (
|
|
335
426
|
qs.values("underlying_instrument__name")
|
|
336
427
|
.annotate(total_value_fx_portfolio=Sum("total_value_fx_portfolio"), weighting=Sum("weighting"))
|
|
@@ -350,7 +441,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
350
441
|
if exclude_index:
|
|
351
442
|
# We exclude only index that are not considered as cash. Setting exclude_cash to true convers this case.
|
|
352
443
|
qs = qs.exclude(
|
|
353
|
-
Q(
|
|
444
|
+
Q(underlying_instrument__instrument_type=InstrumentType.INDEX)
|
|
445
|
+
& Q(underlying_instrument__is_cash=False)
|
|
354
446
|
)
|
|
355
447
|
if extra_filter_parameters:
|
|
356
448
|
qs = qs.filter(**extra_filter_parameters)
|
|
@@ -384,7 +476,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
384
476
|
val_date=val_date,
|
|
385
477
|
exclude_cash=True,
|
|
386
478
|
exclude_index=True,
|
|
387
|
-
extra_filter_parameters={"
|
|
479
|
+
extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
|
|
388
480
|
**kwargs,
|
|
389
481
|
)
|
|
390
482
|
if not df.empty:
|
|
@@ -397,7 +489,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
397
489
|
val_date=val_date,
|
|
398
490
|
exclude_cash=True,
|
|
399
491
|
exclude_index=True,
|
|
400
|
-
extra_filter_parameters={"
|
|
492
|
+
extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
|
|
401
493
|
**kwargs,
|
|
402
494
|
)
|
|
403
495
|
if not df.empty:
|
|
@@ -462,33 +554,49 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
462
554
|
def get_portfolio_contribution_df(self, start, end, with_cash=True, hedged_currency=None, only_equity=False):
|
|
463
555
|
qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
|
|
464
556
|
if only_equity:
|
|
465
|
-
qs = qs.filter(
|
|
557
|
+
qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
|
|
466
558
|
return Portfolio.get_contribution_df(qs, hedged_currency=hedged_currency)
|
|
467
559
|
|
|
468
560
|
def check_related_portfolio_at_date(self, val_date: date, related_portfolio: "Portfolio"):
|
|
469
561
|
assets = AssetPosition.objects.filter(
|
|
470
562
|
date=val_date, underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
471
|
-
).values("
|
|
563
|
+
).values("underlying_instrument", "shares")
|
|
472
564
|
assets1 = assets.filter(portfolio=self)
|
|
473
565
|
assets2 = assets.filter(portfolio=related_portfolio)
|
|
474
566
|
return assets1.difference(assets2)
|
|
475
567
|
|
|
568
|
+
def get_child_portfolios(self, val_date: date) -> set["Portfolio"]:
|
|
569
|
+
child_portfolios = set()
|
|
570
|
+
if pms_instruments := list(self.pms_instruments):
|
|
571
|
+
for parent_portfolio in Portfolio.objects.filter(
|
|
572
|
+
id__in=AssetPosition.objects.filter(date=val_date, underlying_quote__in=pms_instruments).values(
|
|
573
|
+
"portfolio"
|
|
574
|
+
)
|
|
575
|
+
):
|
|
576
|
+
child_portfolios.add(parent_portfolio)
|
|
577
|
+
return child_portfolios
|
|
578
|
+
|
|
579
|
+
def get_parent_portfolios(self, val_date: date) -> set["Portfolio"]:
|
|
580
|
+
for asset in self.assets.filter(date=val_date, underlying_instrument__portfolios__isnull=False).distinct(
|
|
581
|
+
"underlying_instrument"
|
|
582
|
+
):
|
|
583
|
+
if portfolio := asset.underlying_instrument.portfolio:
|
|
584
|
+
yield portfolio, asset.weighting
|
|
585
|
+
|
|
476
586
|
def change_at_date(
|
|
477
587
|
self,
|
|
478
588
|
val_date: date,
|
|
479
589
|
recompute_weighting: bool = False,
|
|
480
590
|
force_recompute_weighting: bool = False,
|
|
481
|
-
|
|
482
|
-
**sync_kwargs,
|
|
591
|
+
compute_metrics: bool = False,
|
|
483
592
|
):
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
.distinct()
|
|
593
|
+
logger.info(f"change at date for {self} at {val_date}")
|
|
594
|
+
qs = self.assets.filter(date=val_date).filter(
|
|
595
|
+
Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
|
|
488
596
|
)
|
|
489
597
|
|
|
490
598
|
# We normalize weight across the portfolio for a given date
|
|
491
|
-
if (self.
|
|
599
|
+
if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
|
|
492
600
|
total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
|
|
493
601
|
# We check if this actually necessary
|
|
494
602
|
# (i.e. if the weight is already summed to 100%, it is already normalized)
|
|
@@ -501,283 +609,257 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
501
609
|
elif total_weighting:
|
|
502
610
|
asset.weighting = asset.weighting / total_weighting
|
|
503
611
|
asset.save()
|
|
504
|
-
if synchronize:
|
|
505
|
-
for dependent_portfolio in self.dependent_portfolios.exclude(id=self.id).distinct():
|
|
506
|
-
# Check if the dependent portfolio has a synchronization method and has assets at the specified date
|
|
507
|
-
if (synchronization := dependent_portfolio.portfolio_synchronization) and (
|
|
508
|
-
dependent_portfolio.assets.filter(date__gte=val_date).exists()
|
|
509
|
-
):
|
|
510
|
-
# If this is true, we want to apply the synchronization at every synchronization period
|
|
511
|
-
# (scheduled crontab) from val_date to now.
|
|
512
|
-
if synchronization.propagate_history:
|
|
513
|
-
for _d in synchronization.dates_range(
|
|
514
|
-
val_date, dependent_portfolio.assets.latest("date").date, filter_daily=True
|
|
515
|
-
):
|
|
516
|
-
synchronization.synchronize_as_task_si(
|
|
517
|
-
dependent_portfolio, _d, override_execution_datetime_validity=True
|
|
518
|
-
).apply_async()
|
|
519
|
-
# Otherwise, we simply call a unique task for that date
|
|
520
|
-
else:
|
|
521
|
-
synchronization.synchronize_as_task_si(
|
|
522
|
-
dependent_portfolio, val_date, override_execution_datetime_validity=True
|
|
523
|
-
).apply_async()
|
|
524
|
-
|
|
525
|
-
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
526
|
-
for instrument in self.instruments.all():
|
|
527
|
-
if price_computation := getattr(
|
|
528
|
-
get_casted_portfolio_instrument(instrument), "price_computation", None
|
|
529
|
-
):
|
|
530
|
-
inception_date = instrument.inception_date
|
|
531
|
-
if isinstance(inception_date, datetime):
|
|
532
|
-
inception_date = inception_date.date()
|
|
533
|
-
|
|
534
|
-
if isinstance(val_date, datetime):
|
|
535
|
-
val_date = val_date.date()
|
|
536
|
-
|
|
537
|
-
if inception_date is None or inception_date > val_date:
|
|
538
|
-
instrument.inception_date = val_date
|
|
539
|
-
instrument.save()
|
|
540
|
-
price_computation.compute(instrument, val_date, override_execution_datetime_validity=True)
|
|
541
|
-
compute_metrics(val_date, basket=self)
|
|
542
|
-
|
|
543
|
-
def propagate_or_update_assets(
|
|
544
|
-
self,
|
|
545
|
-
from_date: date,
|
|
546
|
-
to_date: date,
|
|
547
|
-
forward_price: bool | None = True,
|
|
548
|
-
base_assets: dict[str, str] | None = None,
|
|
549
|
-
delete_existing_assets: bool | None = False,
|
|
550
|
-
):
|
|
551
|
-
# we don't propagate on already imported portfolio by default
|
|
552
|
-
is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
|
|
553
|
-
if not base_assets:
|
|
554
|
-
base_assets = dict()
|
|
555
612
|
|
|
556
|
-
|
|
557
|
-
|
|
613
|
+
# We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
|
|
614
|
+
self.estimate_net_asset_values(val_date)
|
|
615
|
+
self.evaluate_rebalancing(val_date)
|
|
616
|
+
|
|
617
|
+
self.updated_at = timezone.now()
|
|
618
|
+
self.save()
|
|
619
|
+
|
|
620
|
+
if compute_metrics:
|
|
621
|
+
compute_metrics_as_task.delay(
|
|
622
|
+
val_date, basket_id=self.id, basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id
|
|
623
|
+
)
|
|
624
|
+
self.handle_controlling_portfolio_change_at_date(val_date)
|
|
625
|
+
|
|
626
|
+
def handle_controlling_portfolio_change_at_date(self, val_date: date):
|
|
627
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
628
|
+
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
|
|
629
|
+
):
|
|
630
|
+
portfolio_total_asset_value = (
|
|
631
|
+
self.get_total_asset_under_management(val_date) if not self.only_weighting else None
|
|
632
|
+
)
|
|
633
|
+
rel.portfolio.compute_lookthrough(val_date, portfolio_total_asset_value=portfolio_total_asset_value)
|
|
634
|
+
for rel in PortfolioPortfolioThroughModel.objects.filter(
|
|
635
|
+
dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
|
|
636
|
+
):
|
|
637
|
+
rel.portfolio.evaluate_rebalancing(val_date)
|
|
638
|
+
for dependent_portfolio in self.get_child_portfolios(val_date):
|
|
639
|
+
dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
|
|
640
|
+
|
|
641
|
+
def evaluate_rebalancing(self, val_date: date):
|
|
642
|
+
# if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a trade proposal automatically
|
|
643
|
+
next_business_date = (val_date + BDay(1)).date()
|
|
644
|
+
|
|
645
|
+
if hasattr(self, "automatic_rebalancer"):
|
|
646
|
+
logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
|
|
647
|
+
if self.automatic_rebalancer.is_valid(next_business_date):
|
|
648
|
+
self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
|
|
649
|
+
|
|
650
|
+
def estimate_net_asset_values(self, val_date: date):
|
|
651
|
+
for instrument in self.pms_instruments:
|
|
652
|
+
if net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path:
|
|
653
|
+
logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
|
|
654
|
+
net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
|
|
655
|
+
estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument)
|
|
656
|
+
if estimated_net_asset_value is not None:
|
|
657
|
+
InstrumentPrice.objects.update_or_create(
|
|
658
|
+
instrument=instrument,
|
|
659
|
+
date=val_date,
|
|
660
|
+
calculated=True,
|
|
661
|
+
defaults={
|
|
662
|
+
"gross_value": estimated_net_asset_value,
|
|
663
|
+
"net_value": estimated_net_asset_value,
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
if (
|
|
667
|
+
val_date == instrument.prices.latest("date").date
|
|
668
|
+
): # if price date is the latest instrument price date, we recompute the last valuation data
|
|
669
|
+
instrument.update_last_valuation_date()
|
|
670
|
+
instrument.update_last_valuation_date()
|
|
671
|
+
|
|
672
|
+
def get_estimated_portfolio_from_weights(
|
|
673
|
+
self, val_date: date, weights: dict[int, float], prices: dict[int, Decimal] | None = None
|
|
674
|
+
) -> Iterable[AssetPosition]:
|
|
675
|
+
"""
|
|
676
|
+
Given weights and the corresponding instrument price, instantiate asset positions (as AssetPosition object)
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
val_date: The positions valuation date
|
|
680
|
+
weights: The positions weights as dictionary (Instrument IDS as key and weights as values)
|
|
681
|
+
prices: The prices associated with each position
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
Yield AssetPosition objects.
|
|
685
|
+
"""
|
|
686
|
+
if prices is None:
|
|
687
|
+
prices = dict()
|
|
688
|
+
try:
|
|
689
|
+
currency_fx_rate_portfolio_to_usd = CurrencyFXRates.objects.get(date=val_date, currency=self.currency)
|
|
690
|
+
except CurrencyFXRates.DoesNotExist:
|
|
691
|
+
currency_fx_rate_portfolio_to_usd = None
|
|
692
|
+
for underlying_quote_id, next_weight in weights.items():
|
|
693
|
+
underlying_quote = Instrument.objects.get(id=underlying_quote_id)
|
|
694
|
+
position = AssetPosition(
|
|
695
|
+
underlying_quote=underlying_quote,
|
|
696
|
+
weighting=next_weight,
|
|
697
|
+
date=val_date,
|
|
698
|
+
asset_valuation_date=val_date,
|
|
699
|
+
is_estimated=True,
|
|
700
|
+
portfolio=self,
|
|
701
|
+
currency=underlying_quote.currency,
|
|
702
|
+
currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
|
|
703
|
+
initial_price=prices.get(underlying_quote_id, None),
|
|
704
|
+
initial_currency_fx_rate=None,
|
|
705
|
+
currency_fx_rate_instrument_to_usd=None,
|
|
706
|
+
underlying_quote_price=None,
|
|
707
|
+
underlying_instrument=None,
|
|
708
|
+
)
|
|
709
|
+
position.pre_save()
|
|
710
|
+
yield position
|
|
711
|
+
|
|
712
|
+
def batch_portfolio(self, start_date: date, end_date: date):
|
|
713
|
+
"""
|
|
714
|
+
Create the cumulative portfolios between the two given dates and stop at the first rebalancing (if any)
|
|
715
|
+
|
|
716
|
+
Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
|
|
717
|
+
"""
|
|
718
|
+
analytic_portfolio = self.get_analytic_portfolio(start_date)
|
|
719
|
+
initial_assets = analytic_portfolio.assets
|
|
720
|
+
positions = []
|
|
721
|
+
next_trade_proposal = None
|
|
722
|
+
rebalancing_date = None
|
|
723
|
+
returns, prices = self.get_returns(initial_assets, (start_date - BDay(3)).date(), end_date, ffill_returns=True)
|
|
724
|
+
for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
|
|
725
|
+
to_date = to_date_ts.date()
|
|
726
|
+
if rebalancer := getattr(self, "automatic_rebalancer", None):
|
|
727
|
+
if rebalancer.is_valid(to_date):
|
|
728
|
+
rebalancing_date = to_date
|
|
729
|
+
break
|
|
730
|
+
# with suppress(IndexError):
|
|
731
|
+
last_returns = returns.loc[[to_date_ts], :]
|
|
732
|
+
next_weights = analytic_portfolio.get_next_weights(last_returns.iloc[-1, :].T)
|
|
733
|
+
positions.extend(
|
|
734
|
+
self.get_estimated_portfolio_from_weights(
|
|
735
|
+
to_date, next_weights, prices.loc[to_date_ts, :].dropna().to_dict()
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
analytic_portfolio = AnalyticPortfolio(X=last_returns, weights=next_weights)
|
|
739
|
+
|
|
740
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False)
|
|
741
|
+
if rebalancing_date:
|
|
742
|
+
next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
|
|
558
743
|
|
|
559
|
-
|
|
560
|
-
fx_rates = CurrencyFXRates.objects.filter(date=last_fx_date)
|
|
561
|
-
assets = self.assets.filter(date=from_date)
|
|
744
|
+
return next_trade_proposal
|
|
562
745
|
|
|
746
|
+
def propagate_or_update_assets(self, from_date: date, to_date: date):
|
|
747
|
+
"""
|
|
748
|
+
Create a new portfolio at `to_date` based on the portfolio in `from_date`.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
from_date: The date to propagate the portfolio from
|
|
752
|
+
to_date: The date to create the new portfolio at
|
|
753
|
+
|
|
754
|
+
"""
|
|
563
755
|
from_is_active = self.is_active_at_date(from_date)
|
|
564
756
|
to_is_active = self.is_active_at_date(to_date)
|
|
565
|
-
# # We check is the current assets are already stored and if there is no already stored valid assets
|
|
566
|
-
# # With this, we ensure that we don't overwrite imported asset position with propagated ones.
|
|
567
|
-
# assets_positions_next_day_count = self.assets.filter(date=to_date).count()
|
|
568
|
-
if assets.exists() or base_assets:
|
|
569
|
-
# Remove already existing assets
|
|
570
|
-
if delete_existing_assets:
|
|
571
|
-
self.assets.filter(date=to_date).delete()
|
|
572
|
-
asset_list = list()
|
|
573
|
-
# If base_assets is provided,
|
|
574
|
-
# we assume that the portfolio composition is injected by this list of dictionary
|
|
575
|
-
if base_assets:
|
|
576
|
-
base_assets = (
|
|
577
|
-
base_assets
|
|
578
|
-
if isinstance(base_assets, dict)
|
|
579
|
-
else {asset_id: Decimal(1 / len(base_assets)) for asset_id in base_assets}
|
|
580
|
-
)
|
|
581
757
|
|
|
582
|
-
|
|
583
|
-
#
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
)
|
|
607
|
-
remaining_base_assets.pop(asset.underlying_instrument.id, None)
|
|
608
|
-
# We ensure that the propagation assets list contains the proposed composition
|
|
609
|
-
for asset_id, weighting in remaining_base_assets.items():
|
|
610
|
-
instrument = Instrument.objects.get(id=asset_id)
|
|
611
|
-
with suppress(ValueError):
|
|
612
|
-
asset_list.append(
|
|
613
|
-
{
|
|
614
|
-
"underlying_instrument": instrument,
|
|
615
|
-
"initial_price": instrument.get_price(from_date),
|
|
616
|
-
"next_initial_price": instrument.get_price(to_date),
|
|
617
|
-
"asset_valuation_date": to_date,
|
|
618
|
-
"initial_shares": None,
|
|
619
|
-
"portfolio": self,
|
|
620
|
-
"currency": instrument.currency,
|
|
621
|
-
"weighting": weighting,
|
|
622
|
-
}
|
|
758
|
+
def _parse_position(asset: AssetPosition) -> AssetPosition:
|
|
759
|
+
# function to handle the position modification after instantiation
|
|
760
|
+
if from_is_active and not to_is_active:
|
|
761
|
+
asset.weighting = Decimal(0.0)
|
|
762
|
+
asset.initial_shares = AssetPosition.objects.filter(
|
|
763
|
+
date=from_date, underlying_quote=asset.underlying_quote, portfolio=self
|
|
764
|
+
).aggregate(sum_shares=Sum("initial_shares"))["sum_shares"]
|
|
765
|
+
return asset
|
|
766
|
+
|
|
767
|
+
# we don't propagate on already imported portfolio by default
|
|
768
|
+
is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
|
|
769
|
+
if (
|
|
770
|
+
self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
|
|
771
|
+
): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
|
|
772
|
+
analytic_portfolio = self.get_analytic_portfolio(from_date)
|
|
773
|
+
returns, prices = self.get_returns(analytic_portfolio.assets, (from_date - BDay(3)).date(), to_date)
|
|
774
|
+
if not returns.empty:
|
|
775
|
+
weights = analytic_portfolio.get_next_weights(returns.iloc[-1, :].T)
|
|
776
|
+
positions = list(
|
|
777
|
+
map(
|
|
778
|
+
lambda a: _parse_position(a),
|
|
779
|
+
self.get_estimated_portfolio_from_weights(
|
|
780
|
+
to_date, weights, prices.iloc[-1, :].T.dropna().to_dict()
|
|
781
|
+
),
|
|
623
782
|
)
|
|
783
|
+
)
|
|
784
|
+
self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
|
|
624
785
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
(df["next_weighting"] < -1) | (df["next_weighting"] > 1), "weighting"
|
|
652
|
-
] # if the next weighting is not including within -1 and 1 range, we default to the initial weighting
|
|
653
|
-
if not df.empty:
|
|
654
|
-
for row in df.to_dict("records"):
|
|
655
|
-
weighting = Decimal(row["next_weighting"]) if row["next_weighting"] else row["weighting"]
|
|
656
|
-
if from_is_active and not to_is_active:
|
|
657
|
-
weighting = Decimal(0.0)
|
|
658
|
-
try:
|
|
659
|
-
initial_currency_fx_rate = (
|
|
660
|
-
fx_rates.get(currency=self.currency).value
|
|
661
|
-
/ fx_rates.get(currency=row["currency"]).value
|
|
662
|
-
)
|
|
663
|
-
except CurrencyFXRates.DoesNotExist:
|
|
664
|
-
initial_currency_fx_rate = Decimal(1)
|
|
665
|
-
defaults = {
|
|
666
|
-
"initial_currency_fx_rate": initial_currency_fx_rate,
|
|
667
|
-
"weighting": weighting,
|
|
668
|
-
"initial_price": Decimal(row["next_initial_price"]),
|
|
669
|
-
"initial_shares": row["initial_shares"],
|
|
670
|
-
"asset_valuation_date": row["asset_valuation_date"],
|
|
671
|
-
"is_estimated": True,
|
|
672
|
-
}
|
|
673
|
-
get_parameters = {
|
|
674
|
-
"underlying_instrument": row["underlying_instrument"],
|
|
675
|
-
"portfolio": self,
|
|
676
|
-
"currency": row["currency"],
|
|
677
|
-
"date": to_date,
|
|
678
|
-
}
|
|
679
|
-
if exchange := row.get("exchange", None):
|
|
680
|
-
get_parameters["exchange"] = exchange
|
|
681
|
-
if portfolio_created := row.get("portfolio_created", None):
|
|
682
|
-
get_parameters["portfolio_created"] = portfolio_created
|
|
683
|
-
# We check if an asset position already exists and if so, if it is estimated
|
|
684
|
-
# (otherwise we don't propagate it)
|
|
685
|
-
if _asset := AssetPosition.objects.filter(**get_parameters).first():
|
|
686
|
-
_asset.underlying_instrument_price = None # we unset the previously linked underlying instrument price in case it was linked to the wrong underlying price (e.g too early)
|
|
687
|
-
if not from_is_active and not to_is_active:
|
|
688
|
-
_asset.delete()
|
|
689
|
-
elif not is_target_portfolio_imported and _asset.is_estimated:
|
|
690
|
-
for k, v in defaults.items():
|
|
691
|
-
setattr(_asset, k, v)
|
|
692
|
-
_asset.save()
|
|
693
|
-
elif from_is_active and to_is_active and not is_target_portfolio_imported:
|
|
694
|
-
AssetPosition.objects.create(**get_parameters, **defaults)
|
|
695
|
-
|
|
696
|
-
def import_positions_at_date(self, portfolio: PortfolioDTO, val_date: date, post_processing: bool = False):
|
|
697
|
-
if not portfolio:
|
|
698
|
-
return
|
|
699
|
-
left_over_positions = self.assets.filter(date=val_date)
|
|
700
|
-
|
|
701
|
-
# We convert the positions into a dataframe in order to handle positions that are considered duplicates
|
|
702
|
-
# In that case, we sum up fields such as weighting and shares.
|
|
703
|
-
# Position are assumed serialized otherwise the groupby on dataframe can't handle django object
|
|
704
|
-
index_columns = ["portfolio_id", "date", "underlying_instrument_id", "portfolio_created_id"]
|
|
705
|
-
float_columns = [
|
|
706
|
-
"weighting",
|
|
707
|
-
"initial_currency_fx_rate",
|
|
708
|
-
"initial_shares",
|
|
709
|
-
"initial_price",
|
|
710
|
-
]
|
|
711
|
-
df = portfolio.to_df().rename(
|
|
712
|
-
columns={
|
|
713
|
-
"currency_fx_rate": "initial_currency_fx_rate",
|
|
714
|
-
"shares": "initial_shares",
|
|
715
|
-
"price": "initial_price",
|
|
716
|
-
"currency": "currency_id",
|
|
717
|
-
"underlying_instrument": "underlying_instrument_id",
|
|
718
|
-
"portfolio_created": "portfolio_created_id",
|
|
719
|
-
"exchange": "exchange_id",
|
|
720
|
-
}
|
|
721
|
-
)
|
|
722
|
-
df["portfolio_id"] = self.id
|
|
723
|
-
df = df[index_columns + float_columns + ["is_estimated", "currency_id"]]
|
|
724
|
-
df[float_columns] = df[float_columns].astype("float")
|
|
725
|
-
df = df.groupby(index_columns, as_index=False, dropna=False).agg(
|
|
726
|
-
{
|
|
727
|
-
**{field: "first" for field in df.columns.difference(index_columns + float_columns)},
|
|
728
|
-
"weighting": "sum",
|
|
729
|
-
"initial_shares": "sum",
|
|
730
|
-
"initial_currency_fx_rate": "mean",
|
|
731
|
-
"initial_price": "mean",
|
|
732
|
-
}
|
|
733
|
-
)
|
|
734
|
-
df = df.replace([np.inf, -np.inf, np.nan], None)
|
|
735
|
-
|
|
736
|
-
for position in df.to_dict("records"):
|
|
737
|
-
obj, _ = AssetPosition.unannotated_objects.update_or_create(
|
|
738
|
-
portfolio_id=position["portfolio_id"],
|
|
739
|
-
date=position["date"],
|
|
740
|
-
underlying_instrument_id=position["underlying_instrument_id"],
|
|
741
|
-
portfolio_created_id=position["portfolio_created_id"],
|
|
742
|
-
defaults=position,
|
|
743
|
-
)
|
|
744
|
-
left_over_positions = left_over_positions.exclude(id=obj.id)
|
|
745
|
-
left_over_positions.delete()
|
|
746
|
-
if post_processing:
|
|
747
|
-
trigger_portfolio_change_as_task.delay(self.id, val_date)
|
|
748
|
-
|
|
749
|
-
def resynchronize_history(self, from_date: date, to_date: date, instrument: Instrument | None = None):
|
|
750
|
-
if (synchronisation_method := self.portfolio_synchronization) and self.assets.exists():
|
|
751
|
-
if not from_date:
|
|
752
|
-
from_date = self.assets.earliest("date").date
|
|
753
|
-
if not to_date:
|
|
754
|
-
to_date = self.assets.latest("date").date
|
|
755
|
-
# loop over every week day and trigger synchronization task in order
|
|
756
|
-
if to_date <= from_date:
|
|
757
|
-
raise ValueError("bound needs to be valid")
|
|
758
|
-
for sync_datetime in synchronisation_method.dates_range(from_date, to_date, filter_daily=True):
|
|
759
|
-
synchronisation_method.synchronize(
|
|
760
|
-
self, sync_datetime.date(), override_execution_datetime_validity=True
|
|
786
|
+
def get_lookthrough_positions(
|
|
787
|
+
self,
|
|
788
|
+
sync_date: date,
|
|
789
|
+
portfolio_total_asset_value: Decimal | None = None,
|
|
790
|
+
with_intermediary_position: bool = False,
|
|
791
|
+
):
|
|
792
|
+
"""Recursively calculates the look-through position for a portfolio
|
|
793
|
+
|
|
794
|
+
Arguments:
|
|
795
|
+
sync_date {datetime.date} -- The date on which the assets will be computed
|
|
796
|
+
portfolio_total_value: {Decimal} -- The total value of the portfolio (needed to compute initial shares)
|
|
797
|
+
"""
|
|
798
|
+
|
|
799
|
+
def _crawl_portfolio(
|
|
800
|
+
parent_portfolio,
|
|
801
|
+
adjusted_weighting,
|
|
802
|
+
adjusted_currency_fx_rate,
|
|
803
|
+
adjusted_is_estimated,
|
|
804
|
+
portfolio_created=None,
|
|
805
|
+
):
|
|
806
|
+
for position in parent_portfolio.assets.filter(date=sync_date):
|
|
807
|
+
position.id = None
|
|
808
|
+
position.weighting = adjusted_weighting * position.weighting
|
|
809
|
+
position.initial_currency_fx_rate = adjusted_currency_fx_rate * position.currency_fx_rate
|
|
810
|
+
position.is_estimated = (adjusted_is_estimated or position.is_estimated) and not (
|
|
811
|
+
position.weighting == 1.0
|
|
761
812
|
)
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
price_computation_method.compute(
|
|
779
|
-
instrument, sync_datetime.date(), override_execution_datetime_validity=True
|
|
813
|
+
position.portfolio_created = portfolio_created
|
|
814
|
+
position.parent_portfolio = parent_portfolio
|
|
815
|
+
position.initial_shares = None
|
|
816
|
+
if portfolio_total_asset_value:
|
|
817
|
+
position.initial_shares = (position.weighting * portfolio_total_asset_value) / (
|
|
818
|
+
position.price * position.currency_fx_rate
|
|
819
|
+
)
|
|
820
|
+
if child_portfolio := position.underlying_quote.primary_portfolio:
|
|
821
|
+
if with_intermediary_position:
|
|
822
|
+
yield position
|
|
823
|
+
yield from _crawl_portfolio(
|
|
824
|
+
child_portfolio,
|
|
825
|
+
position.weighting,
|
|
826
|
+
position.currency_fx_rate,
|
|
827
|
+
position.is_estimated,
|
|
828
|
+
portfolio_created=child_portfolio,
|
|
780
829
|
)
|
|
830
|
+
elif position.weighting: # we do not yield postion with weight 0 because of issue with certain multi-thematic portfolios which contain duplicates
|
|
831
|
+
yield position
|
|
832
|
+
|
|
833
|
+
yield from _crawl_portfolio(self, Decimal(1.0), Decimal(1.0), False)
|
|
834
|
+
|
|
835
|
+
def get_positions(self, val_date: date, **kwargs) -> Iterable[AssetPosition]:
|
|
836
|
+
if self.is_composition:
|
|
837
|
+
assets = list(self.get_lookthrough_positions(val_date, **kwargs))
|
|
838
|
+
else:
|
|
839
|
+
assets = self.assets.filter(date=val_date)
|
|
840
|
+
return assets
|
|
841
|
+
|
|
842
|
+
def compute_lookthrough(self, sync_date: date, portfolio_total_asset_value: Decimal | None = None):
|
|
843
|
+
if not self.primary_portfolio or not self.is_lookthrough:
|
|
844
|
+
raise ValueError(
|
|
845
|
+
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
846
|
+
)
|
|
847
|
+
logger.info(f"Compute Look-Through for {self} at {sync_date}")
|
|
848
|
+
positions = self.primary_portfolio.get_lookthrough_positions(sync_date, portfolio_total_asset_value)
|
|
849
|
+
self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=True)
|
|
850
|
+
|
|
851
|
+
def batch_recompute_lookthrough(self, from_date: date, to_date: date):
|
|
852
|
+
if not self.primary_portfolio or not self.is_lookthrough:
|
|
853
|
+
raise ValueError(
|
|
854
|
+
"Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
|
|
855
|
+
)
|
|
856
|
+
positions = []
|
|
857
|
+
for val_date in pd.date_range(from_date, to_date, freq="B").date:
|
|
858
|
+
portfolio_total_asset_value = (
|
|
859
|
+
self.get_total_asset_under_management(val_date) if not self.only_weighting else None
|
|
860
|
+
)
|
|
861
|
+
positions.extend(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value))
|
|
862
|
+
self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=False)
|
|
781
863
|
|
|
782
864
|
def update_preferred_classification_per_instrument(self):
|
|
783
865
|
# Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
|
|
@@ -834,6 +916,39 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
834
916
|
def get_representation_label_key(cls):
|
|
835
917
|
return "{{name}}"
|
|
836
918
|
|
|
919
|
+
def bulk_create_positions(self, positions: list[AssetPosition], delete_leftovers: bool = False, **kwargs):
|
|
920
|
+
if positions:
|
|
921
|
+
update_dates = set()
|
|
922
|
+
for position in positions:
|
|
923
|
+
position.portfolio = self
|
|
924
|
+
update_dates.add(position.date)
|
|
925
|
+
self.assets.filter(date__in=update_dates, is_estimated=True).delete()
|
|
926
|
+
leftover_positions = self.assets.filter(date__in=update_dates).all()
|
|
927
|
+
objs = AssetPosition.objects.bulk_create(
|
|
928
|
+
positions,
|
|
929
|
+
update_fields=[
|
|
930
|
+
"weighting",
|
|
931
|
+
"initial_price",
|
|
932
|
+
"initial_currency_fx_rate",
|
|
933
|
+
"initial_shares",
|
|
934
|
+
"currency_fx_rate_instrument_to_usd",
|
|
935
|
+
"currency_fx_rate_portfolio_to_usd",
|
|
936
|
+
"underlying_quote_price",
|
|
937
|
+
"is_estimated",
|
|
938
|
+
"portfolio",
|
|
939
|
+
"portfolio_created",
|
|
940
|
+
"underlying_instrument",
|
|
941
|
+
],
|
|
942
|
+
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
943
|
+
update_conflicts=True,
|
|
944
|
+
)
|
|
945
|
+
if delete_leftovers:
|
|
946
|
+
for leftover_position in leftover_positions:
|
|
947
|
+
if leftover_position not in objs: # this works because __eq__ of a django model use the id field
|
|
948
|
+
leftover_position.delete()
|
|
949
|
+
for update_date in sorted(update_dates):
|
|
950
|
+
self.change_at_date(update_date, **kwargs)
|
|
951
|
+
|
|
837
952
|
@classmethod
|
|
838
953
|
def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
|
|
839
954
|
if isinstance(portfolio_data, int):
|
|
@@ -846,12 +961,48 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
846
961
|
def check_share_diff(self, val_date: date) -> bool:
|
|
847
962
|
return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
|
|
848
963
|
|
|
964
|
+
def get_returns(
|
|
965
|
+
self, instruments: Iterable, from_date: date, to_date: date, ffill_returns: bool = True
|
|
966
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
967
|
+
"""
|
|
968
|
+
Utility methods to get instrument returns for a given date range
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
instruments: instrument to get the returns from
|
|
972
|
+
from_date: date range lower bound
|
|
973
|
+
to_date: date range upper bound
|
|
974
|
+
|
|
975
|
+
Returns:
|
|
976
|
+
Return a tuple of the returns and the last prices series for conveniance
|
|
977
|
+
"""
|
|
978
|
+
prices = InstrumentPrice.objects.filter(
|
|
979
|
+
instrument__in=instruments, date__gte=from_date, date__lte=to_date
|
|
980
|
+
).annotate(
|
|
981
|
+
# fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", self.currency),
|
|
982
|
+
price_fx_portfolio=F("net_value") # * F("net_value")
|
|
983
|
+
)
|
|
984
|
+
prices_df = (
|
|
985
|
+
pd.DataFrame(
|
|
986
|
+
prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
|
|
987
|
+
columns=["instrument", "price_fx_portfolio", "date"],
|
|
988
|
+
)
|
|
989
|
+
.pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
|
|
990
|
+
.astype(float)
|
|
991
|
+
.sort_index()
|
|
992
|
+
)
|
|
993
|
+
ts = pd.bdate_range(from_date, to_date, freq="B")
|
|
994
|
+
prices_df = prices_df.reindex(ts)
|
|
995
|
+
if ffill_returns:
|
|
996
|
+
prices_df = prices_df.ffill()
|
|
997
|
+
returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
|
|
998
|
+
return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df
|
|
999
|
+
|
|
849
1000
|
@classmethod
|
|
850
1001
|
def get_contribution_df(
|
|
851
1002
|
cls,
|
|
852
1003
|
qs,
|
|
853
1004
|
need_normalize=False,
|
|
854
|
-
groupby_label_id="
|
|
1005
|
+
groupby_label_id="underlying_instrument",
|
|
855
1006
|
groubpy_label_title="underlying_instrument__name_repr",
|
|
856
1007
|
currency_fx_rate_label="currency_fx_rate",
|
|
857
1008
|
hedged_currency=None,
|
|
@@ -875,7 +1026,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
875
1026
|
),
|
|
876
1027
|
).select_related("underlying_instrument")
|
|
877
1028
|
df = pd.DataFrame(
|
|
878
|
-
qs.
|
|
1029
|
+
qs.values_list(
|
|
879
1030
|
"date",
|
|
880
1031
|
"price",
|
|
881
1032
|
"coalesce_currency_fx_rate",
|
|
@@ -1007,10 +1158,43 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
1007
1158
|
return res.replace([np.inf, -np.inf, np.nan], 0)
|
|
1008
1159
|
return pd.DataFrame()
|
|
1009
1160
|
|
|
1161
|
+
def get_or_create_index(self):
|
|
1162
|
+
index = Index.objects.create(name=self.name, currency=self.currency)
|
|
1163
|
+
index.portfolios.all().delete()
|
|
1164
|
+
InstrumentPortfolioThroughModel.objects.update_or_create(instrument=index, defaults={"portfolio": self})
|
|
1165
|
+
|
|
1166
|
+
@classmethod
|
|
1167
|
+
def create_model_portfolio(cls, name: str, currency: Currency, with_index: bool = True):
|
|
1168
|
+
portfolio = cls.objects.create(
|
|
1169
|
+
is_manageable=True,
|
|
1170
|
+
name=name,
|
|
1171
|
+
currency=currency,
|
|
1172
|
+
)
|
|
1173
|
+
if with_index:
|
|
1174
|
+
portfolio.get_or_create_index()
|
|
1175
|
+
return portfolio
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def default_estimate_net_value(val_date: date, instrument: Instrument) -> float | None:
|
|
1179
|
+
portfolio = instrument.portfolio
|
|
1180
|
+
if (
|
|
1181
|
+
previous_date := portfolio.get_latest_asset_position_date(val_date - BDay(1), with_estimated=True)
|
|
1182
|
+
) and portfolio.assets.filter(date=val_date).exists():
|
|
1183
|
+
analytic_portfolio = portfolio.get_analytic_portfolio(val_date, with_previous_weights=True)
|
|
1184
|
+
with suppress(InstrumentPrice.DoesNotExist, IndexError):
|
|
1185
|
+
if not instrument.prices.filter(date__lte=previous_date).exists():
|
|
1186
|
+
previous_net_asset_value = instrument.issue_price
|
|
1187
|
+
else:
|
|
1188
|
+
previous_net_asset_value = (
|
|
1189
|
+
InstrumentPrice.objects.filter_only_valid_prices()
|
|
1190
|
+
.get(instrument=instrument, date=previous_date)
|
|
1191
|
+
.net_value
|
|
1192
|
+
)
|
|
1193
|
+
return analytic_portfolio.get_estimate_net_value(float(previous_net_asset_value))
|
|
1194
|
+
|
|
1010
1195
|
|
|
1011
1196
|
@receiver(post_save, sender="wbportfolio.Product")
|
|
1012
1197
|
@receiver(post_save, sender="wbportfolio.ProductGroup")
|
|
1013
|
-
@receiver(post_save, sender="wbportfolio.Index")
|
|
1014
1198
|
def post_product_creation(sender, instance, created, raw, **kwargs):
|
|
1015
1199
|
if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
|
|
1016
1200
|
portfolio = Portfolio.objects.create(
|
|
@@ -1021,11 +1205,19 @@ def post_product_creation(sender, instance, created, raw, **kwargs):
|
|
|
1021
1205
|
InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
|
|
1022
1206
|
|
|
1023
1207
|
|
|
1024
|
-
@
|
|
1025
|
-
def
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1208
|
+
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
1209
|
+
def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
|
|
1210
|
+
if (
|
|
1211
|
+
not raw
|
|
1212
|
+
and created
|
|
1213
|
+
and instance.portfolio.is_lookthrough
|
|
1214
|
+
and instance.type == PortfolioPortfolioThroughModel.Type.PRIMARY
|
|
1215
|
+
):
|
|
1216
|
+
with suppress(AssetPosition.DoesNotExist):
|
|
1217
|
+
earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
|
|
1218
|
+
batch_recompute_lookthrough_as_task.delay(
|
|
1219
|
+
instance.portfolio.id, earliest_primary_position_date, date.today()
|
|
1220
|
+
)
|
|
1029
1221
|
|
|
1030
1222
|
|
|
1031
1223
|
@shared_task(queue="portfolio")
|
|
@@ -1035,6 +1227,20 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
|
|
|
1035
1227
|
|
|
1036
1228
|
|
|
1037
1229
|
@shared_task(queue="portfolio")
|
|
1038
|
-
def
|
|
1230
|
+
def batch_recompute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
|
|
1039
1231
|
portfolio = Portfolio.objects.get(id=portfolio_id)
|
|
1040
|
-
portfolio.
|
|
1232
|
+
portfolio.batch_recompute_lookthrough(start, end)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
@receiver(investable_universe_updated, sender="wbfdm.Instrument")
|
|
1236
|
+
def update_portfolio_after_investable_universe(*args, end_date: date | None = None, **kwargs):
|
|
1237
|
+
if not end_date:
|
|
1238
|
+
end_date = (date.today() - BDay(1)).date()
|
|
1239
|
+
from_date = (end_date - BDay(1)).date()
|
|
1240
|
+
for portfolio in Portfolio.tracked_objects.filter(is_lookthrough=False).to_dependency_iterator(from_date):
|
|
1241
|
+
logger.info(f"computing next weight for {portfolio} from {from_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
|
|
1242
|
+
try:
|
|
1243
|
+
portfolio.propagate_or_update_assets(from_date, end_date)
|
|
1244
|
+
except Exception as e:
|
|
1245
|
+
logger.error(f"Exception while propagating portfolio assets {portfolio}: {e}")
|
|
1246
|
+
portfolio.estimate_net_asset_values(end_date)
|