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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import date
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import TYPE_CHECKING, Iterable
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from celery import chain, group
|
|
10
|
+
from django.contrib.contenttypes.models import ContentType
|
|
11
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
12
|
+
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
13
|
+
from wbfdm.contrib.metric.tasks import compute_metrics_as_task
|
|
14
|
+
from wbfdm.models import Instrument
|
|
15
|
+
|
|
16
|
+
from wbportfolio.models.asset import AssetPosition
|
|
17
|
+
from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from wbportfolio.models import Portfolio
|
|
21
|
+
logger = logging.getLogger("pms")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
MINIMUM_DECIMAL = 8
|
|
25
|
+
MIN_STEP = Decimal("0.00000001")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssetPositionBuilder:
|
|
29
|
+
"""
|
|
30
|
+
Efficiently converts position data into AssetPosition models with batch operations
|
|
31
|
+
and proper dependency management.
|
|
32
|
+
|
|
33
|
+
Features:
|
|
34
|
+
- Bulk database fetching for performance
|
|
35
|
+
- Thread-safe operations
|
|
36
|
+
- Clear type hints
|
|
37
|
+
- Memory-efficient storage
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_positions: dict[date, dict[tuple[int, int | None], "AssetPosition"]]
|
|
41
|
+
|
|
42
|
+
_fx_rates: dict[date, dict[Currency, CurrencyFXRates]]
|
|
43
|
+
_instruments: dict[int, Instrument]
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
portfolio: "Portfolio",
|
|
48
|
+
):
|
|
49
|
+
self.portfolio = portfolio
|
|
50
|
+
# Initialize data stores with type hints
|
|
51
|
+
self._instruments = {}
|
|
52
|
+
self._fx_rates = defaultdict(dict)
|
|
53
|
+
self.prices = defaultdict(dict)
|
|
54
|
+
self.returns = pd.DataFrame()
|
|
55
|
+
self._compute_metrics_tasks = set()
|
|
56
|
+
self._change_at_date_tasks = dict()
|
|
57
|
+
self._positions = defaultdict(dict)
|
|
58
|
+
self.excluded_positions = defaultdict(list)
|
|
59
|
+
self._change_at_date_kwargs = {}
|
|
60
|
+
|
|
61
|
+
def get_positions(self, fix_quantization: bool = True, **kwargs):
|
|
62
|
+
# return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
|
|
63
|
+
for val_date, positions in self._positions.items():
|
|
64
|
+
excluded_positions = self.excluded_positions.get(val_date, [])
|
|
65
|
+
total_excluded_position_weight = (
|
|
66
|
+
sum(map(lambda o: o.weighting, excluded_positions)) if excluded_positions else Decimal("0")
|
|
67
|
+
)
|
|
68
|
+
quantization_weight_error = round(
|
|
69
|
+
Decimal("1") - total_excluded_position_weight - sum(map(lambda o: o.weighting, positions.values()))
|
|
70
|
+
if fix_quantization
|
|
71
|
+
else Decimal("0"),
|
|
72
|
+
MINIMUM_DECIMAL,
|
|
73
|
+
)
|
|
74
|
+
for position in sorted(positions.values(), key=lambda x: x.weighting, reverse=True):
|
|
75
|
+
if position.weighting:
|
|
76
|
+
for k, v in kwargs.items():
|
|
77
|
+
setattr(position, k, v)
|
|
78
|
+
# if the total weight is not 100%, we add the quantization leftover to some random position (max 1e-8 per position, thus it is negligible)
|
|
79
|
+
if quantization_weight_error:
|
|
80
|
+
step = round(Decimal(math.copysign(MIN_STEP, quantization_weight_error)), MINIMUM_DECIMAL)
|
|
81
|
+
position.weighting += step
|
|
82
|
+
quantization_weight_error -= step
|
|
83
|
+
yield position
|
|
84
|
+
|
|
85
|
+
def __bool__(self) -> bool:
|
|
86
|
+
return len(self._positions.keys()) > 0
|
|
87
|
+
|
|
88
|
+
def _get_instrument(self, instrument_id: int) -> Instrument:
|
|
89
|
+
try:
|
|
90
|
+
return self._instruments[instrument_id]
|
|
91
|
+
except KeyError:
|
|
92
|
+
instrument = Instrument.objects.get(id=instrument_id)
|
|
93
|
+
self._instruments[instrument_id] = instrument
|
|
94
|
+
return instrument
|
|
95
|
+
|
|
96
|
+
def _get_fx_rate(self, val_date: date, currency: Currency) -> CurrencyFXRates | None:
|
|
97
|
+
try:
|
|
98
|
+
return self._fx_rates[val_date][currency]
|
|
99
|
+
except KeyError:
|
|
100
|
+
if currency.key == "USD":
|
|
101
|
+
fx_rate = CurrencyFXRates.objects.get_or_create(
|
|
102
|
+
currency=currency, date=val_date, defaults={"value": Decimal("1")}
|
|
103
|
+
)[0]
|
|
104
|
+
else:
|
|
105
|
+
try:
|
|
106
|
+
fx_rate = CurrencyFXRates.objects.get(
|
|
107
|
+
currency=currency, date=val_date
|
|
108
|
+
) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
|
|
109
|
+
except CurrencyFXRates.DoesNotExist:
|
|
110
|
+
fx_rate = CurrencyFXRates.objects.filter(currency=currency, date__lt=val_date).latest("date")
|
|
111
|
+
self._fx_rates[val_date][currency] = fx_rate
|
|
112
|
+
return fx_rate
|
|
113
|
+
|
|
114
|
+
def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
|
|
115
|
+
try:
|
|
116
|
+
return self.prices[val_date][instrument.id]
|
|
117
|
+
except KeyError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def _dict_to_model(
|
|
121
|
+
self,
|
|
122
|
+
val_date: date,
|
|
123
|
+
instrument_id: int,
|
|
124
|
+
weighting: float,
|
|
125
|
+
**kwargs,
|
|
126
|
+
) -> "AssetPosition":
|
|
127
|
+
underlying_quote = self._get_instrument(instrument_id)
|
|
128
|
+
currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
|
|
129
|
+
currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
|
|
130
|
+
if underlying_quote.is_cash:
|
|
131
|
+
price = Decimal("1")
|
|
132
|
+
else:
|
|
133
|
+
price = self._get_price(val_date, underlying_quote)
|
|
134
|
+
|
|
135
|
+
parameters = dict(
|
|
136
|
+
underlying_quote=underlying_quote,
|
|
137
|
+
weighting=round(weighting, MINIMUM_DECIMAL),
|
|
138
|
+
date=val_date,
|
|
139
|
+
asset_valuation_date=val_date,
|
|
140
|
+
is_estimated=True,
|
|
141
|
+
portfolio=self.portfolio,
|
|
142
|
+
currency=underlying_quote.currency,
|
|
143
|
+
initial_price=price,
|
|
144
|
+
currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
|
|
145
|
+
currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
|
|
146
|
+
initial_currency_fx_rate=None,
|
|
147
|
+
underlying_quote_price=None,
|
|
148
|
+
underlying_instrument=None,
|
|
149
|
+
)
|
|
150
|
+
parameters.update(kwargs)
|
|
151
|
+
position = AssetPosition(**parameters)
|
|
152
|
+
return position
|
|
153
|
+
|
|
154
|
+
def load_returns(self, instrument_ids: Iterable[int], from_date: date, to_date: date, use_dl: bool = True):
|
|
155
|
+
if self.returns.empty:
|
|
156
|
+
self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
|
|
157
|
+
from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
min_date = min(self.prices.keys())
|
|
161
|
+
max_date = max(self.prices.keys())
|
|
162
|
+
if from_date < min_date or to_date > max_date:
|
|
163
|
+
# we need to refetch everything as we are missing index
|
|
164
|
+
self.prices, self.returns = Instrument.objects.filter(
|
|
165
|
+
id__in=set(instrument_ids).union(set(self.returns.columns))
|
|
166
|
+
).get_returns_df(
|
|
167
|
+
from_date=min(from_date, min_date),
|
|
168
|
+
to_date=max(to_date, max_date),
|
|
169
|
+
to_currency=self.portfolio.currency,
|
|
170
|
+
use_dl=use_dl,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
instruments = set(instrument_ids) - set(self.returns.columns)
|
|
174
|
+
if instruments:
|
|
175
|
+
new_prices, new_returns = Instrument.objects.filter(id__in=instruments).get_returns_df(
|
|
176
|
+
from_date=min(from_date, min_date),
|
|
177
|
+
to_date=max(to_date, max_date),
|
|
178
|
+
to_currency=self.portfolio.currency,
|
|
179
|
+
use_dl=use_dl,
|
|
180
|
+
)
|
|
181
|
+
self.returns = self.returns.join(new_returns, how="left").fillna(0)
|
|
182
|
+
for d, p in new_prices.items():
|
|
183
|
+
self.prices[d].update(p)
|
|
184
|
+
|
|
185
|
+
def add(
|
|
186
|
+
self,
|
|
187
|
+
positions: list["AssetPosition"] | tuple[date, dict[int, float]],
|
|
188
|
+
infer_underlying_quote_price: bool = False,
|
|
189
|
+
):
|
|
190
|
+
"""
|
|
191
|
+
Add multiple positions efficiently with batch processing
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
positions: Iterable of AssetPosition instances or dictionary of weight {instrument_id: weight} that needs to be converted into AssetPosition
|
|
195
|
+
"""
|
|
196
|
+
if isinstance(positions, tuple):
|
|
197
|
+
val_date = positions[0]
|
|
198
|
+
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
199
|
+
for position in positions:
|
|
200
|
+
if not isinstance(position, AssetPosition):
|
|
201
|
+
position = self._dict_to_model(*position)
|
|
202
|
+
position.pre_save(
|
|
203
|
+
infer_underlying_quote_price=infer_underlying_quote_price
|
|
204
|
+
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
205
|
+
# Generate unique composite key
|
|
206
|
+
key = (
|
|
207
|
+
position.underlying_quote.id,
|
|
208
|
+
position.portfolio_created.id if position.portfolio_created else None,
|
|
209
|
+
)
|
|
210
|
+
# Merge duplicate positions
|
|
211
|
+
if existing_position := self._positions[position.date].get(key):
|
|
212
|
+
position.weighting += existing_position.weighting
|
|
213
|
+
if existing_position.initial_shares:
|
|
214
|
+
position.initial_shares += existing_position.initial_shares
|
|
215
|
+
# ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
|
|
216
|
+
position.portfolio = self.portfolio
|
|
217
|
+
position.weighting = Decimal(
|
|
218
|
+
round(position.weighting, 8)
|
|
219
|
+
) # set the weight as it will be saved in the db to handle quantization error accordingly
|
|
220
|
+
if position.initial_price is not None and position.initial_currency_fx_rate is not None:
|
|
221
|
+
self._positions[position.date][key] = position
|
|
222
|
+
else:
|
|
223
|
+
self.excluded_positions[position.date].append(position)
|
|
224
|
+
self._change_at_date_kwargs["fix_quantization"] = True
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def get_dates(self) -> list[date]:
|
|
228
|
+
"""Get sorted list of unique dates"""
|
|
229
|
+
return list(sorted(self._positions.keys()))
|
|
230
|
+
|
|
231
|
+
def _get_portfolio(self, val_date: date) -> AnalyticPortfolio:
|
|
232
|
+
"""Get weight structure with instrument IDs as keys"""
|
|
233
|
+
positions = self._positions[val_date]
|
|
234
|
+
next_returns = self.returns.loc[[(val_date + BDay(1)).date()], :]
|
|
235
|
+
weights = dict(map(lambda row: (row[1].underlying_quote.id, float(row[1].weighting)), positions.items()))
|
|
236
|
+
return AnalyticPortfolio(weights=weights, X=next_returns)
|
|
237
|
+
|
|
238
|
+
def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
|
|
239
|
+
positions = list(self.get_positions(**kwargs))
|
|
240
|
+
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
241
|
+
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
242
|
+
# change completely the trades of a portfolio model and drift it.
|
|
243
|
+
dates = self.get_dates()
|
|
244
|
+
self.portfolio.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
245
|
+
|
|
246
|
+
if len(positions) > 0:
|
|
247
|
+
if self.portfolio.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
248
|
+
leftover_positions_ids = list(
|
|
249
|
+
self.portfolio.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
250
|
+
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
251
|
+
logger.info(f"bulk saving {len(positions)} positions ({len(leftover_positions_ids)} leftovers) ...")
|
|
252
|
+
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
253
|
+
positions,
|
|
254
|
+
update_fields=[
|
|
255
|
+
"weighting",
|
|
256
|
+
"initial_price",
|
|
257
|
+
"initial_currency_fx_rate",
|
|
258
|
+
"initial_shares",
|
|
259
|
+
"currency_fx_rate_instrument_to_usd",
|
|
260
|
+
"currency_fx_rate_portfolio_to_usd",
|
|
261
|
+
"underlying_quote_price",
|
|
262
|
+
"portfolio",
|
|
263
|
+
"portfolio_created",
|
|
264
|
+
"underlying_instrument",
|
|
265
|
+
],
|
|
266
|
+
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
267
|
+
update_conflicts=True,
|
|
268
|
+
batch_size=10000,
|
|
269
|
+
)
|
|
270
|
+
if delete_leftovers:
|
|
271
|
+
objs_ids = list(map(lambda x: x.id, objs))
|
|
272
|
+
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
273
|
+
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
274
|
+
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
275
|
+
|
|
276
|
+
for val_date in self.get_dates():
|
|
277
|
+
if self.portfolio.is_tracked:
|
|
278
|
+
try:
|
|
279
|
+
changed_portfolio = self._get_portfolio(val_date)
|
|
280
|
+
except KeyError:
|
|
281
|
+
changed_portfolio = None
|
|
282
|
+
self._change_at_date_tasks[val_date] = changed_portfolio
|
|
283
|
+
self._compute_metrics_tasks.add(val_date)
|
|
284
|
+
self._positions = defaultdict(dict)
|
|
285
|
+
|
|
286
|
+
def clear(self):
|
|
287
|
+
self.excluded_positions = defaultdict(list)
|
|
288
|
+
|
|
289
|
+
def schedule_metric_computation(self):
|
|
290
|
+
if self._compute_metrics_tasks:
|
|
291
|
+
basket_id = self.portfolio.id
|
|
292
|
+
basket_content_type_id = ContentType.objects.get_by_natural_key("wbportfolio", "portfolio").id
|
|
293
|
+
group(
|
|
294
|
+
*[
|
|
295
|
+
compute_metrics_as_task.si(d, basket_id=basket_id, basket_content_type_id=basket_content_type_id)
|
|
296
|
+
for d in self._compute_metrics_tasks
|
|
297
|
+
]
|
|
298
|
+
).apply_async()
|
|
299
|
+
self._change_at_date_tasks = dict()
|
|
300
|
+
|
|
301
|
+
def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
|
|
302
|
+
from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
|
|
303
|
+
|
|
304
|
+
change_at_date_kwargs = task_kwargs
|
|
305
|
+
change_at_date_kwargs.update(self._change_at_date_kwargs)
|
|
306
|
+
if self._change_at_date_tasks:
|
|
307
|
+
tasks = chain(
|
|
308
|
+
*[
|
|
309
|
+
trigger_portfolio_change_as_task.si(
|
|
310
|
+
self.portfolio.id,
|
|
311
|
+
d,
|
|
312
|
+
changed_portfolio=portfolio,
|
|
313
|
+
evaluate_rebalancer=False,
|
|
314
|
+
**change_at_date_kwargs,
|
|
315
|
+
)
|
|
316
|
+
for d, portfolio in self._change_at_date_tasks.items()
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
if synchronous:
|
|
320
|
+
tasks.apply()
|
|
321
|
+
else:
|
|
322
|
+
tasks.apply_async()
|
|
323
|
+
self._change_at_date_tasks = dict()
|
wbportfolio/models/custodians.py
CHANGED
|
@@ -32,7 +32,7 @@ class Custodian(WBModel):
|
|
|
32
32
|
|
|
33
33
|
@classmethod
|
|
34
34
|
def get_by_mapping(cls, mapping: str, use_similarity=False, create_missing=True):
|
|
35
|
-
|
|
35
|
+
similarity_score = 0.7
|
|
36
36
|
lower_mapping = mapping.lower()
|
|
37
37
|
try:
|
|
38
38
|
return cls.objects.get(mapping__contains=[lower_mapping])
|
|
@@ -40,7 +40,7 @@ class Custodian(WBModel):
|
|
|
40
40
|
if use_similarity:
|
|
41
41
|
similar_custodians = cls.objects.annotate(
|
|
42
42
|
similarity_score=TrigramSimilarity("name", lower_mapping)
|
|
43
|
-
).filter(similarity_score__gt=
|
|
43
|
+
).filter(similarity_score__gt=similarity_score)
|
|
44
44
|
if similar_custodians.count() == 1:
|
|
45
45
|
custodian = similar_custodians.first()
|
|
46
46
|
print(f"find similar custodian {lower_mapping} -> {custodian.name}") # noqa: T201
|
|
@@ -50,7 +50,7 @@ class Custodian(WBModel):
|
|
|
50
50
|
else:
|
|
51
51
|
similar_companies = Company.objects.annotate(
|
|
52
52
|
similarity_score=TrigramSimilarity("name", lower_mapping)
|
|
53
|
-
).filter(similarity_score__gt=
|
|
53
|
+
).filter(similarity_score__gt=similarity_score)
|
|
54
54
|
if similar_companies.count() == 1:
|
|
55
55
|
print( # noqa: T201
|
|
56
56
|
f"Find similar company {lower_mapping} -> {similar_companies.first().name}"
|
wbportfolio/models/exceptions.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
class
|
|
1
|
+
class InvalidAnalyticPortfolioError(Exception):
|
|
2
2
|
pass
|
|
@@ -120,7 +120,7 @@ class PortfolioGraph:
|
|
|
120
120
|
if rel.dependency_portfolio.is_composition:
|
|
121
121
|
label += " (Composition)"
|
|
122
122
|
|
|
123
|
-
if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.
|
|
123
|
+
if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH:
|
|
124
124
|
self.graph.add_edge(
|
|
125
125
|
pydot.Edge(
|
|
126
126
|
str(rel.portfolio.id), str(rel.dependency_portfolio.id), label="Look-Through", style="dotted"
|
|
@@ -3,21 +3,21 @@ import plotly.graph_objects as go
|
|
|
3
3
|
from networkx.drawing.nx_agraph import graphviz_layout
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def reformat_graph_layout(
|
|
6
|
+
def reformat_graph_layout(g, layout):
|
|
7
7
|
"""
|
|
8
8
|
this method provide positions based on layout algorithm
|
|
9
|
-
:param
|
|
9
|
+
:param g:
|
|
10
10
|
:param layout:
|
|
11
11
|
:return:
|
|
12
12
|
"""
|
|
13
13
|
if layout == "graphviz":
|
|
14
|
-
positions = graphviz_layout(
|
|
14
|
+
positions = graphviz_layout(g)
|
|
15
15
|
elif layout == "spring":
|
|
16
|
-
positions = nx.fruchterman_reingold_layout(
|
|
16
|
+
positions = nx.fruchterman_reingold_layout(g, k=0.5, iterations=1000)
|
|
17
17
|
elif layout == "spectral":
|
|
18
|
-
positions = nx.spectral_layout(
|
|
18
|
+
positions = nx.spectral_layout(g, scale=0.1)
|
|
19
19
|
elif layout == "random":
|
|
20
|
-
positions = nx.random_layout(
|
|
20
|
+
positions = nx.random_layout(g)
|
|
21
21
|
else:
|
|
22
22
|
raise Exception("please specify the layout from graphviz, spring, spectral or random")
|
|
23
23
|
|
|
@@ -25,7 +25,7 @@ def reformat_graph_layout(G, layout):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def networkx_graph_to_plotly(
|
|
28
|
-
|
|
28
|
+
g: nx.Graph,
|
|
29
29
|
labels: dict[str, str] | None = None,
|
|
30
30
|
node_size: int = 10,
|
|
31
31
|
edge_weight: int = 1,
|
|
@@ -36,12 +36,12 @@ def networkx_graph_to_plotly(
|
|
|
36
36
|
"""
|
|
37
37
|
Visualize a NetworkX graph using Plotly.
|
|
38
38
|
"""
|
|
39
|
-
positions = reformat_graph_layout(
|
|
39
|
+
positions = reformat_graph_layout(g, layout)
|
|
40
40
|
if not labels:
|
|
41
41
|
labels = {}
|
|
42
42
|
# Initialize edge traces
|
|
43
43
|
edge_traces = []
|
|
44
|
-
for edge in
|
|
44
|
+
for edge in g.edges():
|
|
45
45
|
x0, y0 = positions[edge[0]]
|
|
46
46
|
x1, y1 = positions[edge[1]]
|
|
47
47
|
|
|
@@ -52,12 +52,12 @@ def networkx_graph_to_plotly(
|
|
|
52
52
|
|
|
53
53
|
# Initialize node trace
|
|
54
54
|
node_x, node_y, node_colors, node_labels = [], [], [], []
|
|
55
|
-
for node in
|
|
55
|
+
for node in g.nodes():
|
|
56
56
|
x, y = positions[node]
|
|
57
57
|
node_x.append(x)
|
|
58
58
|
node_y.append(y)
|
|
59
59
|
node_labels.append(labels.get(node, node))
|
|
60
|
-
node_colors.append(len(list(
|
|
60
|
+
node_colors.append(len(list(g.neighbors(node)))) # Color based on degree
|
|
61
61
|
|
|
62
62
|
node_trace = go.Scatter(
|
|
63
63
|
x=node_x,
|
|
@@ -150,6 +150,13 @@ class PMSInstrumentAbstractModel(PMSInstrument):
|
|
|
150
150
|
default="wbportfolio.models.portfolio.default_estimate_net_value",
|
|
151
151
|
verbose_name="NAV Computation Method",
|
|
152
152
|
)
|
|
153
|
+
order_routing_custodian_adapter = models.CharField(
|
|
154
|
+
blank=True,
|
|
155
|
+
null=True,
|
|
156
|
+
max_length=1024,
|
|
157
|
+
verbose_name="Order Routing Custodian Adapter",
|
|
158
|
+
help_text="The dotted path to the order routing custodian adapter",
|
|
159
|
+
)
|
|
153
160
|
risk_scale = models.IntegerField(
|
|
154
161
|
validators=[MinValueValidator(1), MaxValueValidator(7)],
|
|
155
162
|
default=4,
|
|
@@ -834,7 +834,7 @@ class LiquidityStressMixin:
|
|
|
834
834
|
|
|
835
835
|
""" The main function for the liquidity stress tests """
|
|
836
836
|
|
|
837
|
-
def liquidity_stress_test(
|
|
837
|
+
def liquidity_stress_test( # noqa: C901
|
|
838
838
|
self,
|
|
839
839
|
report_date: Optional[date] = None,
|
|
840
840
|
weights_date: Optional[date] = None,
|
|
@@ -966,7 +966,7 @@ class LiquidityStressMixin:
|
|
|
966
966
|
|
|
967
967
|
qs_trades = Trade.objects.filter(
|
|
968
968
|
underlying_instrument__in=product_ids,
|
|
969
|
-
|
|
969
|
+
type__in=["SUBSCRIPTION", "REDEMPTION"],
|
|
970
970
|
transaction_date__lte=report_date,
|
|
971
971
|
).order_by("transaction_date")
|
|
972
972
|
if not qs_trades.exists():
|
|
@@ -974,7 +974,7 @@ class LiquidityStressMixin:
|
|
|
974
974
|
|
|
975
975
|
trades_fields = [
|
|
976
976
|
"transaction_date",
|
|
977
|
-
"
|
|
977
|
+
"type",
|
|
978
978
|
"underlying_instrument",
|
|
979
979
|
"underlying_instrument__currency",
|
|
980
980
|
"total_value",
|
|
@@ -999,7 +999,7 @@ class LiquidityStressMixin:
|
|
|
999
999
|
|
|
1000
1000
|
# df_trades.transaction_date = pd.to_datetime(df_trades["transaction_date"]) # to use df.rolling
|
|
1001
1001
|
# Gross Redemption
|
|
1002
|
-
df_gross_redemption = df_trades.where(df_trades.
|
|
1002
|
+
df_gross_redemption = df_trades.where(df_trades.type == "REDEMPTION")
|
|
1003
1003
|
df_gross_redemption = df_gross_redemption.groupby("date").total_value_usd.sum()
|
|
1004
1004
|
df_gross_redemption.name = "gross_redemption"
|
|
1005
1005
|
df_gross_redemption = df_aum.join(df_gross_redemption)
|