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,1414 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any, Self, TypeVar
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from celery import shared_task
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
12
|
+
from django.db import DatabaseError, models
|
|
13
|
+
from django.db.models import (
|
|
14
|
+
F,
|
|
15
|
+
OuterRef,
|
|
16
|
+
Q,
|
|
17
|
+
QuerySet,
|
|
18
|
+
Subquery,
|
|
19
|
+
Sum,
|
|
20
|
+
Value,
|
|
21
|
+
)
|
|
22
|
+
from django.db.models.functions import Coalesce, Round
|
|
23
|
+
from django.db.models.signals import post_save, pre_delete
|
|
24
|
+
from django.dispatch import receiver
|
|
25
|
+
from django.utils.functional import cached_property
|
|
26
|
+
from django.utils.translation import gettext_lazy
|
|
27
|
+
from django_fsm import FSMField, transition
|
|
28
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
29
|
+
from requests import HTTPError
|
|
30
|
+
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
31
|
+
from wbcore.contrib.authentication.models import User
|
|
32
|
+
from wbcore.contrib.icons import WBIcon
|
|
33
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
34
|
+
from wbcore.contrib.notifications.utils import create_notification_type
|
|
35
|
+
from wbcore.enums import RequestType
|
|
36
|
+
from wbcore.metadata.configs.buttons import ActionButton
|
|
37
|
+
from wbcore.models import WBModel
|
|
38
|
+
from wbcore.utils.models import CloneMixin
|
|
39
|
+
from wbfdm.enums import MarketData
|
|
40
|
+
from wbfdm.models import InstrumentPrice
|
|
41
|
+
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
42
|
+
from wbfdm.signals import investable_universe_updated
|
|
43
|
+
|
|
44
|
+
from wbportfolio.models.asset import AssetPosition
|
|
45
|
+
from wbportfolio.models.roles import PortfolioRole
|
|
46
|
+
from wbportfolio.pms.typing import Order as OrderDTO
|
|
47
|
+
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
48
|
+
|
|
49
|
+
from ...order_routing import ExecutionStatus, RoutingException
|
|
50
|
+
from ...order_routing.router import Router
|
|
51
|
+
from .. import Portfolio
|
|
52
|
+
from .orders import Order
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("pms")
|
|
55
|
+
|
|
56
|
+
SelfOrderProposal = TypeVar("SelfOrderProposal", bound="OrderProposal")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
60
|
+
trade_date = models.DateField(verbose_name="Trading Date")
|
|
61
|
+
|
|
62
|
+
class Status(models.TextChoices):
|
|
63
|
+
DRAFT = "DRAFT", "Draft"
|
|
64
|
+
PENDING = "PENDING", "Pending"
|
|
65
|
+
APPROVED = "APPROVED", "Approved"
|
|
66
|
+
DENIED = "DENIED", "Denied"
|
|
67
|
+
EXECUTION = "EXECUTION", "Execution"
|
|
68
|
+
CONFIRMED = "CONFIRMED", "Confirmed"
|
|
69
|
+
FAILED = "FAILED", "Failed"
|
|
70
|
+
|
|
71
|
+
comment = models.TextField(default="", verbose_name="Order Comment", blank=True)
|
|
72
|
+
status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name="Status")
|
|
73
|
+
rebalancing_model = models.ForeignKey(
|
|
74
|
+
"wbportfolio.RebalancingModel",
|
|
75
|
+
on_delete=models.SET_NULL,
|
|
76
|
+
blank=True,
|
|
77
|
+
null=True,
|
|
78
|
+
related_name="order_proposals",
|
|
79
|
+
verbose_name="Rebalancing Model",
|
|
80
|
+
help_text="Rebalancing Model that generates the target portfolio",
|
|
81
|
+
)
|
|
82
|
+
portfolio = models.ForeignKey(
|
|
83
|
+
"wbportfolio.Portfolio", related_name="order_proposals", on_delete=models.PROTECT, verbose_name="Portfolio"
|
|
84
|
+
)
|
|
85
|
+
creator = models.ForeignKey(
|
|
86
|
+
"directory.Person",
|
|
87
|
+
blank=True,
|
|
88
|
+
null=True,
|
|
89
|
+
related_name="order_proposals",
|
|
90
|
+
on_delete=models.PROTECT,
|
|
91
|
+
verbose_name="Owner",
|
|
92
|
+
)
|
|
93
|
+
approver = models.ForeignKey(
|
|
94
|
+
"directory.Person",
|
|
95
|
+
blank=True,
|
|
96
|
+
null=True,
|
|
97
|
+
related_name="approver_order_proposals",
|
|
98
|
+
on_delete=models.PROTECT,
|
|
99
|
+
verbose_name="Approver",
|
|
100
|
+
)
|
|
101
|
+
min_order_value = models.IntegerField(
|
|
102
|
+
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
103
|
+
)
|
|
104
|
+
min_weighting = models.DecimalField(
|
|
105
|
+
max_digits=9,
|
|
106
|
+
decimal_places=Order.ORDER_WEIGHTING_PRECISION,
|
|
107
|
+
default=Decimal(0),
|
|
108
|
+
help_text="The minimum weight allowed for this order proposal ",
|
|
109
|
+
verbose_name="Minimum Weight",
|
|
110
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
total_cash_weight = models.DecimalField(
|
|
114
|
+
default=Decimal("0"),
|
|
115
|
+
decimal_places=4,
|
|
116
|
+
max_digits=5,
|
|
117
|
+
verbose_name="Total Cash Weight",
|
|
118
|
+
help_text="The desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
119
|
+
validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
|
|
120
|
+
)
|
|
121
|
+
total_effective_portfolio_contribution = models.DecimalField(
|
|
122
|
+
default=Decimal("1"),
|
|
123
|
+
max_digits=Order.ORDER_WEIGHTING_PRECISION * 2 + 3,
|
|
124
|
+
decimal_places=Order.ORDER_WEIGHTING_PRECISION * 2,
|
|
125
|
+
)
|
|
126
|
+
execution_status = models.CharField(
|
|
127
|
+
blank=True, default="", choices=ExecutionStatus.choices, verbose_name="Execution Status"
|
|
128
|
+
)
|
|
129
|
+
execution_status_detail = models.CharField(blank=True, default="", verbose_name="Execution Status Detail")
|
|
130
|
+
execution_comment = models.CharField(blank=True, default="", verbose_name="Execution Comment")
|
|
131
|
+
|
|
132
|
+
class Meta:
|
|
133
|
+
verbose_name = "Order Proposal"
|
|
134
|
+
verbose_name_plural = "Order Proposals"
|
|
135
|
+
constraints = [
|
|
136
|
+
models.UniqueConstraint(
|
|
137
|
+
fields=["portfolio", "trade_date"],
|
|
138
|
+
name="unique_order_proposal",
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
notification_types = [
|
|
143
|
+
create_notification_type(
|
|
144
|
+
"wbportfolio.order_proposal.push_model_changes",
|
|
145
|
+
"Push Model Changes",
|
|
146
|
+
"Sends a notification when a the change/orders are pushed to modeled after portfolios",
|
|
147
|
+
True,
|
|
148
|
+
True,
|
|
149
|
+
True,
|
|
150
|
+
)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def __str__(self) -> str:
|
|
154
|
+
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
155
|
+
|
|
156
|
+
def save(self, *args, **kwargs):
|
|
157
|
+
# if the order proposal is created, we default these fields with the portfolio default value for automatic value assignement
|
|
158
|
+
if not self.id and not self.min_order_value:
|
|
159
|
+
self.min_order_value = self.portfolio.default_order_proposal_min_order_value
|
|
160
|
+
if not self.id and not self.min_weighting:
|
|
161
|
+
self.min_weighting = self.portfolio.default_order_proposal_min_weighting
|
|
162
|
+
if not self.id and not self.total_cash_weight:
|
|
163
|
+
self.total_cash_weight = self.portfolio.default_order_proposal_total_cash_weight
|
|
164
|
+
# if a order proposal is created before the existing earliest order proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
|
|
165
|
+
if not self.portfolio.order_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
166
|
+
# we need to set the inception date as the first order proposal trade date (and thus, the first position date). We expect a NAV at 100 then
|
|
167
|
+
self.portfolio.instruments.filter(inception_date__gt=self.trade_date).update(
|
|
168
|
+
inception_date=self.trade_date
|
|
169
|
+
)
|
|
170
|
+
super().save(*args, **kwargs)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def check_evaluation_date(self):
|
|
174
|
+
return self.trade_date
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def checked_object(self) -> Any:
|
|
178
|
+
return self.portfolio
|
|
179
|
+
|
|
180
|
+
@cached_property
|
|
181
|
+
def portfolio_total_asset_value(self) -> Decimal:
|
|
182
|
+
return self.get_portfolio_total_asset_value()
|
|
183
|
+
|
|
184
|
+
@cached_property
|
|
185
|
+
def last_effective_date(self) -> date:
|
|
186
|
+
try:
|
|
187
|
+
return self.portfolio.assets.filter(date__lt=self.trade_date).latest("date").date
|
|
188
|
+
except AssetPosition.DoesNotExist:
|
|
189
|
+
return self.value_date
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def custodian_router(self) -> Router | None:
|
|
193
|
+
try:
|
|
194
|
+
return Router(self.portfolio.get_authenticated_custodian_adapter(raise_exception=True))
|
|
195
|
+
except ValueError as e:
|
|
196
|
+
logger.warning("Error while instantiating custodian adapter: %s", e)
|
|
197
|
+
|
|
198
|
+
@cached_property
|
|
199
|
+
def value_date(self) -> date:
|
|
200
|
+
return (self.trade_date - BDay(1)).date()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def previous_order_proposal(self) -> SelfOrderProposal | None:
|
|
204
|
+
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
205
|
+
trade_date__lt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
206
|
+
)
|
|
207
|
+
if future_proposals.exists():
|
|
208
|
+
return future_proposals.latest("trade_date")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def next_order_proposal(self) -> SelfOrderProposal | None:
|
|
213
|
+
future_proposals = OrderProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
214
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
215
|
+
)
|
|
216
|
+
if future_proposals.exists():
|
|
217
|
+
return future_proposals.earliest("trade_date")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def cash_component(self) -> Cash:
|
|
222
|
+
return self.portfolio.cash_component
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def total_effective_portfolio_weight(self) -> Decimal:
|
|
226
|
+
return Decimal("1.0")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def total_expected_target_weight(self) -> Decimal:
|
|
230
|
+
return self.total_effective_portfolio_weight - self.total_cash_weight
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def can_be_confirmed(self) -> bool:
|
|
234
|
+
return self.portfolio.can_be_rebalanced and self.status == self.Status.APPROVED
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def can_be_applied(self):
|
|
238
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
239
|
+
|
|
240
|
+
@cached_property
|
|
241
|
+
def total_effective_portfolio_cash_weight(self) -> Decimal:
|
|
242
|
+
return self.portfolio.assets.filter(
|
|
243
|
+
models.Q(date=self.last_effective_date)
|
|
244
|
+
& (models.Q(underlying_quote__is_cash=True) | models.Q(underlying_quote__is_cash_equivalent=True))
|
|
245
|
+
).aggregate(Sum("weighting"))["weighting__sum"] or Decimal("0")
|
|
246
|
+
|
|
247
|
+
def get_portfolio_total_asset_value(self):
|
|
248
|
+
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
249
|
+
# return self.orders.annotate(
|
|
250
|
+
# effective_shares=Coalesce(
|
|
251
|
+
# Subquery(
|
|
252
|
+
# AssetPosition.objects.filter(
|
|
253
|
+
# underlying_quote=OuterRef("underlying_instrument"),
|
|
254
|
+
# date=self.last_effective_date,
|
|
255
|
+
# portfolio=self.portfolio,
|
|
256
|
+
# )
|
|
257
|
+
# .values("portfolio")
|
|
258
|
+
# .annotate(s=Sum("shares"))
|
|
259
|
+
# .values("s")[:1]
|
|
260
|
+
# ),
|
|
261
|
+
# Decimal(0),
|
|
262
|
+
# ),
|
|
263
|
+
# effective_total_value_fx_portfolio=F("effective_shares") * F("currency_fx_rate") * F("price"),
|
|
264
|
+
# ).aggregate(s=Sum("effective_total_value_fx_portfolio"))["s"] or Decimal(0.0)
|
|
265
|
+
|
|
266
|
+
def get_orders(self):
|
|
267
|
+
# TODO Issue here: the cash is subqueried on the portfolio, on portfolio such as the fund, there is multiple cash component, that we exclude in the orders (and use a unique cash position instead)
|
|
268
|
+
# so the subquery returns the previous position (probably USD), but is missing the other cash aggregation. We need to find a way to handle that properly
|
|
269
|
+
|
|
270
|
+
orders = self.orders.all().annotate(
|
|
271
|
+
total_effective_portfolio_contribution=Value(self.total_effective_portfolio_contribution),
|
|
272
|
+
last_effective_date=Subquery(
|
|
273
|
+
AssetPosition.unannotated_objects.filter(
|
|
274
|
+
date__lt=OuterRef("value_date"),
|
|
275
|
+
portfolio=OuterRef("portfolio"),
|
|
276
|
+
)
|
|
277
|
+
.order_by("-date")
|
|
278
|
+
.values("date")[:1]
|
|
279
|
+
),
|
|
280
|
+
previous_weight=models.Case(
|
|
281
|
+
models.When(
|
|
282
|
+
underlying_instrument__is_cash=False,
|
|
283
|
+
then=Coalesce(
|
|
284
|
+
Subquery(
|
|
285
|
+
AssetPosition.unannotated_objects.filter(
|
|
286
|
+
underlying_quote=OuterRef("underlying_instrument"),
|
|
287
|
+
date=OuterRef("last_effective_date"),
|
|
288
|
+
portfolio=OuterRef("portfolio"),
|
|
289
|
+
)
|
|
290
|
+
.values("portfolio")
|
|
291
|
+
.annotate(s=Sum("weighting"))
|
|
292
|
+
.values("s")[:1]
|
|
293
|
+
),
|
|
294
|
+
Decimal(0),
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
default=Value(self.total_effective_portfolio_cash_weight),
|
|
298
|
+
),
|
|
299
|
+
contribution=F("previous_weight") * (F("daily_return") + Value(Decimal("1"))),
|
|
300
|
+
effective_weight=Round(
|
|
301
|
+
models.Case(
|
|
302
|
+
models.When(total_effective_portfolio_contribution=Value(Decimal("0")), then=Value(Decimal("0"))),
|
|
303
|
+
default=F("contribution") / F("total_effective_portfolio_contribution") - F("quantization_error"),
|
|
304
|
+
),
|
|
305
|
+
precision=Order.ORDER_WEIGHTING_PRECISION,
|
|
306
|
+
),
|
|
307
|
+
target_weight=Round(F("effective_weight") + F("weighting"), precision=Order.ORDER_WEIGHTING_PRECISION),
|
|
308
|
+
effective_shares=Coalesce(
|
|
309
|
+
Subquery(
|
|
310
|
+
AssetPosition.objects.filter(
|
|
311
|
+
underlying_quote=OuterRef("underlying_instrument"),
|
|
312
|
+
date=OuterRef("last_effective_date"),
|
|
313
|
+
portfolio=OuterRef("portfolio"),
|
|
314
|
+
)
|
|
315
|
+
.values("portfolio")
|
|
316
|
+
.annotate(s=Sum("shares"))
|
|
317
|
+
.values("s")[:1]
|
|
318
|
+
),
|
|
319
|
+
Decimal(0),
|
|
320
|
+
),
|
|
321
|
+
target_shares=F("effective_shares") + F("shares"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return orders.annotate(
|
|
325
|
+
has_warnings=models.Case(
|
|
326
|
+
models.When(
|
|
327
|
+
(models.Q(price=0) & ~models.Q(target_weight=0)) | models.Q(target_weight__lt=0), then=Value(True)
|
|
328
|
+
),
|
|
329
|
+
default=Value(False),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def get_trades_batch(self):
|
|
334
|
+
return self._get_default_effective_portfolio().get_orders(self.get_target_portfolio())
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def can_be_executed(self) -> bool:
|
|
338
|
+
return (
|
|
339
|
+
self.custodian_router is not None
|
|
340
|
+
and not self.has_non_successful_checks
|
|
341
|
+
and self.status == self.Status.APPROVED
|
|
342
|
+
and (not self.execution_status or self.execution_status == ExecutionStatus.CANCELLED)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def can_execute(self, user: User) -> bool:
|
|
346
|
+
return (not self.approver or user.is_superuser or user != self.approver.user_account) and self.can_be_executed
|
|
347
|
+
|
|
348
|
+
def prepare_orders_for_execution(self, prioritize_target_weight: bool = False) -> list[OrderDTO]:
|
|
349
|
+
"""Prepares executable orders by filtering and converting them for submission.
|
|
350
|
+
|
|
351
|
+
Filters out cash instruments and orders with zero weighting and shares, then
|
|
352
|
+
creates OrderDTOs for those having valid instrument identifiers. Orders with
|
|
353
|
+
unsupported asset classes or missing identifiers are marked as ignored with comments.
|
|
354
|
+
Updates ignored orders in bulk.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
prioritize_target_weight: If True, prioritize target weight over share quantities
|
|
358
|
+
when preparing order quantities.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
A list of OrderDTO objects ready for execution submission.
|
|
362
|
+
"""
|
|
363
|
+
executable_orders = []
|
|
364
|
+
updated_orders = []
|
|
365
|
+
self.orders.update(execution_status=Order.ExecutionStatus.IGNORED)
|
|
366
|
+
for order in (
|
|
367
|
+
self.get_orders()
|
|
368
|
+
.exclude(models.Q(underlying_instrument__is_cash=True) | (models.Q(weighting=0) & models.Q(shares=0)))
|
|
369
|
+
.select_related("underlying_instrument")
|
|
370
|
+
):
|
|
371
|
+
instrument = order.underlying_instrument
|
|
372
|
+
asset_class = instrument.get_security_ancestor().instrument_type.key.upper()
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
if instrument.refinitiv_identifier_code or instrument.ticker or instrument.sedol:
|
|
376
|
+
quantity = {"target_weight": float(order.target_weight)}
|
|
377
|
+
if not prioritize_target_weight and order.shares:
|
|
378
|
+
quantity["shares"] = float(order.shares)
|
|
379
|
+
quantity["target_shares"] = (
|
|
380
|
+
float(order.target_shares) if order.target_shares is not None else None
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
executable_orders.append(
|
|
384
|
+
OrderDTO(
|
|
385
|
+
id=order.id,
|
|
386
|
+
asset_class=OrderDTO.AssetType[asset_class],
|
|
387
|
+
weighting=float(order.weighting),
|
|
388
|
+
trade_date=order.value_date,
|
|
389
|
+
refinitiv_identifier_code=instrument.refinitiv_identifier_code,
|
|
390
|
+
bloomberg_ticker=instrument.bloomberg_ticker,
|
|
391
|
+
sedol=instrument.sedol,
|
|
392
|
+
execution_instruction=order.execution_instruction,
|
|
393
|
+
execution_instruction_parameters=order.execution_instruction_parameters,
|
|
394
|
+
**quantity,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
order.execution_status = Order.ExecutionStatus.FAILED
|
|
399
|
+
order.execution_comment = "Underlying instrument does not have a valid identifier."
|
|
400
|
+
updated_orders.append(order)
|
|
401
|
+
except (AttributeError, KeyError):
|
|
402
|
+
order.execution_status = Order.ExecutionStatus.FAILED
|
|
403
|
+
order.execution_comment = f"Unsupported asset class {asset_class.title()}."
|
|
404
|
+
updated_orders.append(order)
|
|
405
|
+
|
|
406
|
+
Order.objects.bulk_update(updated_orders, ["execution_status", "execution_comment"])
|
|
407
|
+
return executable_orders
|
|
408
|
+
|
|
409
|
+
def handle_orders(self, orders: list[OrderDTO]):
|
|
410
|
+
"""Updates order statuses based on confirmed execution results.
|
|
411
|
+
|
|
412
|
+
For each confirmed order, updates the corresponding database record with its
|
|
413
|
+
execution status, comment, and price when available. Orders not present in the
|
|
414
|
+
confirmation list are marked as failed.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
orders: List of confirmed order DTOs returned from the custodian router.
|
|
418
|
+
"""
|
|
419
|
+
leftover_orders = self.orders.filter(underlying_instrument__is_cash=False).all()
|
|
420
|
+
# portfolio_value = self.portfolio_total_asset_value
|
|
421
|
+
|
|
422
|
+
for confirmed_order in orders:
|
|
423
|
+
with suppress(Order.DoesNotExist):
|
|
424
|
+
order = leftover_orders.get(id=confirmed_order.id)
|
|
425
|
+
order.execution_status = Order.ExecutionStatus.CONFIRMED
|
|
426
|
+
order.execution_comment = confirmed_order.comment
|
|
427
|
+
# if execution_price := confirmed_order.execution_price:
|
|
428
|
+
# order.price = round(Decimal(execution_price), 2)
|
|
429
|
+
# order.execution_status = Order.ExecutionStatus.EXECUTED
|
|
430
|
+
# if shares := confirmed_order.shares:
|
|
431
|
+
# order.set_shares(Decimal(shares), portfolio_value)
|
|
432
|
+
# elif weighting := confirmed_order.weighting:
|
|
433
|
+
# order.set_weighting(Decimal(weighting), portfolio_value)
|
|
434
|
+
order.save()
|
|
435
|
+
leftover_orders = leftover_orders.exclude(id=order.id)
|
|
436
|
+
|
|
437
|
+
leftover_orders.delete()
|
|
438
|
+
self.refresh_cash_position()
|
|
439
|
+
|
|
440
|
+
def execute_orders(self, prioritize_target_weight: bool = False):
|
|
441
|
+
"""Submits prepared orders for execution via the custodian router and updates status.
|
|
442
|
+
|
|
443
|
+
Prepares orders based on the target weight priority, submits them for execution,
|
|
444
|
+
handles confirmed orders on success, and records execution status and comments.
|
|
445
|
+
Logs and marks the execution as failed if submission raises an error.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
prioritize_target_weight: Whether to prioritize target weights when preparing orders.
|
|
449
|
+
"""
|
|
450
|
+
self.status = self.Status.EXECUTION
|
|
451
|
+
orders = self.prepare_orders_for_execution(prioritize_target_weight=prioritize_target_weight)
|
|
452
|
+
try:
|
|
453
|
+
confirmed_orders, (status, rebalancing_comment) = self.custodian_router.submit_rebalancing(orders)
|
|
454
|
+
self.handle_orders(confirmed_orders)
|
|
455
|
+
except (ValueError, RoutingException, HTTPError) as e:
|
|
456
|
+
logger.error(f"Could not execute orders proposal {self}: {e}")
|
|
457
|
+
status = ExecutionStatus.FAILED
|
|
458
|
+
rebalancing_comment = str(e)
|
|
459
|
+
self.execution_status = status
|
|
460
|
+
self.execution_comment = rebalancing_comment
|
|
461
|
+
self.save()
|
|
462
|
+
|
|
463
|
+
def refresh_execution_status(self):
|
|
464
|
+
"""Updates execution status from the custodian router and saves the model.
|
|
465
|
+
|
|
466
|
+
Retrieves the latest rebalance status and details, assigns them to the instance,
|
|
467
|
+
and persists changes to the database.
|
|
468
|
+
"""
|
|
469
|
+
self.execution_status, self.execution_status_detail = self.custodian_router.get_rebalance_status()
|
|
470
|
+
if self.execution_status == ExecutionStatus.COMPLETED:
|
|
471
|
+
self.execution_status = self.Status.CONFIRMED
|
|
472
|
+
self.save()
|
|
473
|
+
|
|
474
|
+
def cancel_rebalancing(self):
|
|
475
|
+
"""Cancels the ongoing rebalance via the custodian router and updates the model.
|
|
476
|
+
|
|
477
|
+
If cancellation succeeds, clears execution details, marks the status as cancelled,
|
|
478
|
+
saves the instance, and returns the cancellation status.
|
|
479
|
+
"""
|
|
480
|
+
cancel_rebalancing_status = self.custodian_router.cancel_rebalancing()
|
|
481
|
+
if cancel_rebalancing_status:
|
|
482
|
+
(
|
|
483
|
+
self.execution_comment,
|
|
484
|
+
self.execution_status_detail,
|
|
485
|
+
self.execution_status,
|
|
486
|
+
) = (
|
|
487
|
+
"",
|
|
488
|
+
"",
|
|
489
|
+
ExecutionStatus.CANCELLED,
|
|
490
|
+
)
|
|
491
|
+
self.save()
|
|
492
|
+
return cancel_rebalancing_status
|
|
493
|
+
|
|
494
|
+
def get_target_portfolio(self):
|
|
495
|
+
positions = []
|
|
496
|
+
instrument_ids = []
|
|
497
|
+
for order in self.get_orders():
|
|
498
|
+
pos = order.to_dto()
|
|
499
|
+
instrument_ids.append(pos.underlying_instrument)
|
|
500
|
+
positions.append(pos)
|
|
501
|
+
|
|
502
|
+
# insert latest market data
|
|
503
|
+
df = pd.DataFrame(
|
|
504
|
+
Instrument.objects.filter(id__in=instrument_ids).dl.market_data(
|
|
505
|
+
[MarketData.MARKET_CAPITALIZATION_CONSOLIDATED, MarketData.VOLUME, MarketData.CLOSE],
|
|
506
|
+
from_date=self.trade_date - timedelta(days=50),
|
|
507
|
+
to_date=self.trade_date,
|
|
508
|
+
target_currency="USD",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
df["volume_50d"] = df["volume"]
|
|
512
|
+
df = (
|
|
513
|
+
df[
|
|
514
|
+
[
|
|
515
|
+
"valuation_date",
|
|
516
|
+
"instrument_id",
|
|
517
|
+
"volume",
|
|
518
|
+
"volume_50d",
|
|
519
|
+
"close",
|
|
520
|
+
"market_capitalization_consolidated",
|
|
521
|
+
]
|
|
522
|
+
]
|
|
523
|
+
.sort_values(by="valuation_date")
|
|
524
|
+
.groupby("instrument_id")
|
|
525
|
+
.agg(
|
|
526
|
+
{
|
|
527
|
+
"volume": "last",
|
|
528
|
+
"volume_50d": "mean",
|
|
529
|
+
"close": "last",
|
|
530
|
+
"market_capitalization_consolidated": "last",
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
df["volume_usd"] = df.volume * df.close
|
|
535
|
+
|
|
536
|
+
for pos in positions:
|
|
537
|
+
if pos.underlying_instrument in df.index:
|
|
538
|
+
pos.market_capitalization_usd = df.loc[pos.underlying_instrument, "market_capitalization_consolidated"]
|
|
539
|
+
pos.volume_usd = (
|
|
540
|
+
df.loc[pos.underlying_instrument, "volume"] * df.loc[pos.underlying_instrument, "close"]
|
|
541
|
+
)
|
|
542
|
+
if pos.shares:
|
|
543
|
+
if volume_50d := df.loc[pos.underlying_instrument, "volume_50d"]:
|
|
544
|
+
pos.daily_liquidity = float(pos.shares) / volume_50d / 0.33
|
|
545
|
+
if pos.market_capitalization_usd:
|
|
546
|
+
pos.market_share = (
|
|
547
|
+
float(pos.shares)
|
|
548
|
+
* df.loc[pos.underlying_instrument, "close"]
|
|
549
|
+
/ pos.market_capitalization_usd
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
return PortfolioDTO(positions)
|
|
553
|
+
|
|
554
|
+
# Start tools methods
|
|
555
|
+
def _clone(self, **kwargs) -> SelfOrderProposal:
|
|
556
|
+
"""
|
|
557
|
+
Method to clone self as a new order proposal. It will automatically shift the order date if a proposal already exists
|
|
558
|
+
Args:
|
|
559
|
+
**kwargs: The keyword arguments
|
|
560
|
+
Returns:
|
|
561
|
+
The cloned order proposal
|
|
562
|
+
"""
|
|
563
|
+
trade_date = kwargs.get("clone_date", self.trade_date)
|
|
564
|
+
|
|
565
|
+
# Find the next valid order date
|
|
566
|
+
while OrderProposal.objects.filter(portfolio=self.portfolio, trade_date=trade_date).exists():
|
|
567
|
+
trade_date += timedelta(days=1)
|
|
568
|
+
|
|
569
|
+
order_proposal_clone = OrderProposal.objects.create(
|
|
570
|
+
trade_date=trade_date,
|
|
571
|
+
comment=kwargs.get("clone_comment", self.comment),
|
|
572
|
+
status=OrderProposal.Status.DRAFT,
|
|
573
|
+
rebalancing_model=self.rebalancing_model,
|
|
574
|
+
portfolio=self.portfolio,
|
|
575
|
+
creator=self.creator,
|
|
576
|
+
)
|
|
577
|
+
for order in self.orders.all():
|
|
578
|
+
order.id = None
|
|
579
|
+
order.order_proposal = order_proposal_clone
|
|
580
|
+
order.save()
|
|
581
|
+
|
|
582
|
+
return order_proposal_clone
|
|
583
|
+
|
|
584
|
+
def normalize_orders(self, total_cash_weight: Decimal):
|
|
585
|
+
"""
|
|
586
|
+
Normalize the orders to accomodate the given cash weight
|
|
587
|
+
"""
|
|
588
|
+
self.total_cash_weight = total_cash_weight
|
|
589
|
+
self.reset_orders()
|
|
590
|
+
|
|
591
|
+
def fix_quantization(self):
|
|
592
|
+
if self.orders.exists():
|
|
593
|
+
orders = self.get_orders()
|
|
594
|
+
t_weight = orders.aggregate(models.Sum("effective_weight"))["effective_weight__sum"] or Decimal("0.0")
|
|
595
|
+
quantization_error = orders.aggregate(models.Sum("quantization_error"))[
|
|
596
|
+
"quantization_error__sum"
|
|
597
|
+
] or Decimal("0.0")
|
|
598
|
+
# we handle quantization error due to the decimal max digits. In that case, we take the biggest order (highest weight) and we remove the quantization error
|
|
599
|
+
if t_weight and (
|
|
600
|
+
quantize_error := ((t_weight + quantization_error) - self.total_effective_portfolio_weight)
|
|
601
|
+
):
|
|
602
|
+
biggest_order = orders.exclude(underlying_instrument__is_cash=True).latest("effective_weight")
|
|
603
|
+
biggest_order.quantization_error = quantize_error
|
|
604
|
+
biggest_order.save()
|
|
605
|
+
|
|
606
|
+
def _get_default_target_portfolio(self, use_desired_target_weight: bool = False, **kwargs) -> PortfolioDTO:
|
|
607
|
+
if self.rebalancing_model:
|
|
608
|
+
params = {}
|
|
609
|
+
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
610
|
+
params.update(rebalancer.parameters)
|
|
611
|
+
params.update(kwargs)
|
|
612
|
+
return self.rebalancing_model.get_target_portfolio(
|
|
613
|
+
self.portfolio, self.trade_date, self.value_date, **params
|
|
614
|
+
)
|
|
615
|
+
return self._get_default_effective_portfolio(
|
|
616
|
+
include_delta_weight=True, use_desired_target_weight=use_desired_target_weight
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def _get_default_effective_portfolio(
|
|
620
|
+
self, include_delta_weight: bool = False, use_desired_target_weight: bool = False
|
|
621
|
+
):
|
|
622
|
+
"""
|
|
623
|
+
Converts the internal portfolio state and pending orders into a PortfolioDTO.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
PortfolioDTO: Object that encapsulates all portfolio positions.
|
|
627
|
+
"""
|
|
628
|
+
portfolio = {}
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
analytic_portfolio = self.portfolio.get_analytic_portfolio(self.last_effective_date, use_dl=True)
|
|
632
|
+
last_returns, contribution = analytic_portfolio.get_contributions()
|
|
633
|
+
last_returns = last_returns.to_dict()
|
|
634
|
+
effective_weights = analytic_portfolio.get_next_weights()
|
|
635
|
+
except ValueError:
|
|
636
|
+
effective_weights, last_returns, contribution = {}, {}, 1
|
|
637
|
+
self.total_effective_portfolio_contribution = Decimal(contribution)
|
|
638
|
+
# 1. Gather all non-cash, positively weighted assets from the existing portfolio.
|
|
639
|
+
for asset in self.portfolio.assets.filter(
|
|
640
|
+
date=self.last_effective_date,
|
|
641
|
+
weighting__gt=0,
|
|
642
|
+
):
|
|
643
|
+
portfolio[asset.underlying_quote] = {
|
|
644
|
+
"shares": asset._shares,
|
|
645
|
+
"weighting": Decimal(effective_weights.get(asset.underlying_quote.id, asset.weighting))
|
|
646
|
+
if not use_desired_target_weight
|
|
647
|
+
else Decimal("0"),
|
|
648
|
+
"price": asset._price,
|
|
649
|
+
"currency_fx_rate": asset._currency_fx_rate,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
# 2. Add or update non-cash orders, possibly overriding weights.
|
|
653
|
+
for order in self.get_orders().filter(
|
|
654
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
655
|
+
):
|
|
656
|
+
order.daily_return = last_returns.get(order.underlying_instrument.id, 0)
|
|
657
|
+
if use_desired_target_weight and order.desired_target_weight:
|
|
658
|
+
weighting = order.desired_target_weight
|
|
659
|
+
else:
|
|
660
|
+
weighting = order._effective_weight
|
|
661
|
+
if include_delta_weight:
|
|
662
|
+
weighting += order.weighting
|
|
663
|
+
portfolio[order.underlying_instrument] = {
|
|
664
|
+
"weighting": weighting,
|
|
665
|
+
"shares": order._effective_shares,
|
|
666
|
+
"price": order.price,
|
|
667
|
+
"currency_fx_rate": order.currency_fx_rate,
|
|
668
|
+
}
|
|
669
|
+
positions = []
|
|
670
|
+
|
|
671
|
+
# 5. Build PositionDTO objects for all instruments.
|
|
672
|
+
for instrument, row in portfolio.items():
|
|
673
|
+
daily_return = Decimal(last_returns.get(instrument.id, 0))
|
|
674
|
+
# Assemble the position object
|
|
675
|
+
pos = Order.create_dto(
|
|
676
|
+
instrument,
|
|
677
|
+
row["weighting"],
|
|
678
|
+
row["price"],
|
|
679
|
+
self.last_effective_date,
|
|
680
|
+
shares=row["shares"],
|
|
681
|
+
daily_return=daily_return,
|
|
682
|
+
currency_fx_rate=row["currency_fx_rate"],
|
|
683
|
+
)
|
|
684
|
+
positions.append(pos)
|
|
685
|
+
total_weighting = sum(map(lambda pos: pos.weighting, positions))
|
|
686
|
+
# 6. Optionally include a cash position to balance the total weighting.
|
|
687
|
+
if (
|
|
688
|
+
portfolio
|
|
689
|
+
and total_weighting
|
|
690
|
+
and self.total_effective_portfolio_weight
|
|
691
|
+
and (cash_weight := self.total_effective_portfolio_weight - total_weighting)
|
|
692
|
+
):
|
|
693
|
+
cash_position = self.get_estimated_target_cash(target_cash_weight=cash_weight)
|
|
694
|
+
positions.append(cash_position._build_dto())
|
|
695
|
+
return PortfolioDTO(positions)
|
|
696
|
+
|
|
697
|
+
def reset_orders(
|
|
698
|
+
self,
|
|
699
|
+
effective_portfolio: PortfolioDTO
|
|
700
|
+
| None = None, # we need to have this parameter as sometime we want to get the effective portfolio from drifted weight (unsaved)
|
|
701
|
+
target_portfolio: PortfolioDTO | None = None,
|
|
702
|
+
use_desired_target_weight: bool = False,
|
|
703
|
+
):
|
|
704
|
+
"""
|
|
705
|
+
Will delete all existing orders and recreate them from the method `create_or_update_trades`
|
|
706
|
+
"""
|
|
707
|
+
if self.rebalancing_model:
|
|
708
|
+
self.orders.all().delete()
|
|
709
|
+
else:
|
|
710
|
+
self.orders.filter(underlying_instrument__is_cash=True).delete()
|
|
711
|
+
self.orders.update(quantization_error=0)
|
|
712
|
+
# delete all existing orders
|
|
713
|
+
# Get effective and target portfolio
|
|
714
|
+
if not effective_portfolio:
|
|
715
|
+
effective_portfolio = self._get_default_effective_portfolio()
|
|
716
|
+
if not target_portfolio:
|
|
717
|
+
target_portfolio = self._get_default_target_portfolio(use_desired_target_weight=use_desired_target_weight)
|
|
718
|
+
|
|
719
|
+
if self.total_cash_weight:
|
|
720
|
+
target_portfolio = target_portfolio.normalize_cash(self.total_cash_weight)
|
|
721
|
+
if target_portfolio:
|
|
722
|
+
objs = []
|
|
723
|
+
portfolio_value = self.portfolio_total_asset_value
|
|
724
|
+
for order_dto in effective_portfolio.get_orders(target_portfolio):
|
|
725
|
+
instrument = Instrument.objects.get(id=order_dto.underlying_instrument)
|
|
726
|
+
# we cannot do a bulk-create because Order is a multi table inheritance
|
|
727
|
+
weighting = round(order_dto.delta_weight, Order.ORDER_WEIGHTING_PRECISION)
|
|
728
|
+
daily_return = order_dto.daily_return
|
|
729
|
+
try:
|
|
730
|
+
order = self.orders.get(underlying_instrument=instrument)
|
|
731
|
+
order.daily_return = daily_return
|
|
732
|
+
except Order.DoesNotExist:
|
|
733
|
+
order = Order(
|
|
734
|
+
underlying_instrument=instrument,
|
|
735
|
+
order_proposal=self,
|
|
736
|
+
value_date=self.trade_date,
|
|
737
|
+
weighting=weighting,
|
|
738
|
+
daily_return=daily_return,
|
|
739
|
+
)
|
|
740
|
+
order.order_type = Order.get_type(
|
|
741
|
+
weighting, round(order_dto.effective_weight, 8), round(order_dto.target_weight, 8)
|
|
742
|
+
)
|
|
743
|
+
order.quantization_error = order_dto.effective_quantization_error
|
|
744
|
+
if order_dto.price:
|
|
745
|
+
order.price = order_dto.price
|
|
746
|
+
order.pre_save()
|
|
747
|
+
order.set_weighting(weighting, portfolio_value)
|
|
748
|
+
order.desired_target_weight = order_dto.target_weight
|
|
749
|
+
|
|
750
|
+
# if we cannot automatically find a price, we consider the stock is invalid and we sell it
|
|
751
|
+
if not order.price and order.weighting > 0:
|
|
752
|
+
order.price = Decimal("0.0")
|
|
753
|
+
order.weighting = -order_dto.effective_weight
|
|
754
|
+
objs.append(order)
|
|
755
|
+
Order.objects.bulk_create(
|
|
756
|
+
objs,
|
|
757
|
+
update_fields=[
|
|
758
|
+
"value_date",
|
|
759
|
+
"weighting",
|
|
760
|
+
"daily_return",
|
|
761
|
+
"currency_fx_rate",
|
|
762
|
+
"order_type",
|
|
763
|
+
"portfolio",
|
|
764
|
+
"price",
|
|
765
|
+
"price_gross",
|
|
766
|
+
"desired_target_weight",
|
|
767
|
+
"quantization_error",
|
|
768
|
+
"shares",
|
|
769
|
+
],
|
|
770
|
+
unique_fields=["order_proposal", "underlying_instrument"],
|
|
771
|
+
update_conflicts=True,
|
|
772
|
+
batch_size=1000,
|
|
773
|
+
)
|
|
774
|
+
# final sanity check to make sure invalid order with effective and target weight of 0 are automatically removed:
|
|
775
|
+
self.get_orders().exclude(underlying_instrument__is_cash=True).filter(
|
|
776
|
+
target_weight=0, effective_weight=0
|
|
777
|
+
).delete()
|
|
778
|
+
self.get_orders().filter(target_weight=0).exclude(effective_shares=0).update(shares=-F("effective_shares"))
|
|
779
|
+
self.fix_quantization()
|
|
780
|
+
self.save()
|
|
781
|
+
|
|
782
|
+
def refresh_cash_position(self):
|
|
783
|
+
self.total_cash_weight = self.total_effective_portfolio_weight - self.get_orders().filter(
|
|
784
|
+
underlying_instrument__is_cash=False
|
|
785
|
+
).aggregate(s=Sum("target_weight"))["s"] or Decimal("0")
|
|
786
|
+
cash_order = None
|
|
787
|
+
try:
|
|
788
|
+
cash_order = Order.objects.get(order_proposal=self, underlying_instrument=self.cash_component)
|
|
789
|
+
except Order.DoesNotExist:
|
|
790
|
+
if self.total_cash_weight:
|
|
791
|
+
cash_order = Order.objects.create(
|
|
792
|
+
order_proposal=self, underlying_instrument=self.cash_component, weighting=Decimal("0")
|
|
793
|
+
)
|
|
794
|
+
if cash_order:
|
|
795
|
+
cash_order.weighting = self.total_cash_weight - cash_order._previous_weight
|
|
796
|
+
cash_order.save()
|
|
797
|
+
|
|
798
|
+
def refresh_returns(self):
|
|
799
|
+
weights = {
|
|
800
|
+
row[0]: float(row[1]) for row in self.get_orders().values_list("underlying_instrument", "previous_weight")
|
|
801
|
+
}
|
|
802
|
+
last_returns, contribution = self.portfolio.get_analytic_portfolio(
|
|
803
|
+
self.value_date, weights=weights, use_dl=True
|
|
804
|
+
).get_contributions()
|
|
805
|
+
last_returns = last_returns.to_dict()
|
|
806
|
+
orders_to_update = []
|
|
807
|
+
for order in self.orders.all():
|
|
808
|
+
with suppress(KeyError):
|
|
809
|
+
order.price = self.portfolio.builder.prices[self.trade_date][order.underlying_instrument.id]
|
|
810
|
+
try:
|
|
811
|
+
order.daily_return = last_returns[order.underlying_instrument.id]
|
|
812
|
+
except KeyError:
|
|
813
|
+
order.daily_return = Decimal("1.0")
|
|
814
|
+
order.quantization_error = Decimal("0")
|
|
815
|
+
orders_to_update.append(order)
|
|
816
|
+
Order.objects.bulk_update(orders_to_update, ["daily_return", "price", "quantization_error"])
|
|
817
|
+
self.total_effective_portfolio_contribution = Decimal(contribution)
|
|
818
|
+
self.save()
|
|
819
|
+
# ensure that sell orders keep having target weight at zero (might happens when returns are refreshed expost)
|
|
820
|
+
for order in self.get_orders().filter(Q(order_type=Order.Type.SELL) & ~Q(weighting=-F("target_weight"))):
|
|
821
|
+
order.weighting = -order.effective_weight
|
|
822
|
+
order.save()
|
|
823
|
+
|
|
824
|
+
# At this point, user needs to manually modify the orders in order to account for ex-post change. I am not sure we should we quantization at that point. To be monitored
|
|
825
|
+
# self.fix_quantization()
|
|
826
|
+
|
|
827
|
+
def replay(
|
|
828
|
+
self,
|
|
829
|
+
broadcast_changes_at_date: bool = True,
|
|
830
|
+
reapply_order_proposal: bool = False,
|
|
831
|
+
synchronous: bool = False,
|
|
832
|
+
**reset_order_kwargs,
|
|
833
|
+
):
|
|
834
|
+
last_order_proposal = self
|
|
835
|
+
last_order_proposal_created = False
|
|
836
|
+
self.portfolio.load_builder_returns((self.trade_date - BDay(3)).date(), date.today())
|
|
837
|
+
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.CONFIRMED:
|
|
838
|
+
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
839
|
+
if not last_order_proposal_created:
|
|
840
|
+
if reapply_order_proposal or last_order_proposal.rebalancing_model:
|
|
841
|
+
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
842
|
+
last_order_proposal.apply_workflow(
|
|
843
|
+
silent_exception=True, force_reset_order=True, **reset_order_kwargs
|
|
844
|
+
)
|
|
845
|
+
last_order_proposal.save()
|
|
846
|
+
else:
|
|
847
|
+
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
848
|
+
last_order_proposal.reset_orders(**reset_order_kwargs)
|
|
849
|
+
if last_order_proposal.status != OrderProposal.Status.CONFIRMED:
|
|
850
|
+
break
|
|
851
|
+
next_order_proposal = last_order_proposal.next_order_proposal
|
|
852
|
+
if next_order_proposal:
|
|
853
|
+
next_trade_date = next_order_proposal.trade_date - timedelta(days=1)
|
|
854
|
+
elif next_expected_rebalancing_date := self.portfolio.get_next_rebalancing_date(
|
|
855
|
+
last_order_proposal.trade_date
|
|
856
|
+
):
|
|
857
|
+
next_trade_date = (
|
|
858
|
+
next_expected_rebalancing_date + timedelta(days=7)
|
|
859
|
+
) # we don't know yet if rebalancing is valid and can be executed on `next_expected_rebalancing_date`, so we add safety window of 7 days
|
|
860
|
+
else:
|
|
861
|
+
next_trade_date = date.today()
|
|
862
|
+
next_trade_date = min(next_trade_date, date.today())
|
|
863
|
+
gen = self.portfolio.drift_weights(
|
|
864
|
+
last_order_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
|
|
865
|
+
)
|
|
866
|
+
try:
|
|
867
|
+
while True:
|
|
868
|
+
self.portfolio.builder.add(next(gen))
|
|
869
|
+
except StopIteration as e:
|
|
870
|
+
overriding_order_proposal = e.value
|
|
871
|
+
|
|
872
|
+
self.portfolio.builder.bulk_create_positions(
|
|
873
|
+
delete_leftovers=True,
|
|
874
|
+
)
|
|
875
|
+
for draft_tp in OrderProposal.objects.filter(
|
|
876
|
+
portfolio=self.portfolio,
|
|
877
|
+
trade_date__gt=last_order_proposal.trade_date,
|
|
878
|
+
trade_date__lte=next_trade_date,
|
|
879
|
+
status=OrderProposal.Status.DRAFT,
|
|
880
|
+
):
|
|
881
|
+
draft_tp.reset_orders()
|
|
882
|
+
if overriding_order_proposal:
|
|
883
|
+
last_order_proposal_created = True
|
|
884
|
+
last_order_proposal = overriding_order_proposal
|
|
885
|
+
else:
|
|
886
|
+
last_order_proposal_created = False
|
|
887
|
+
last_order_proposal = next_order_proposal
|
|
888
|
+
self.portfolio.builder.schedule_change_at_dates(
|
|
889
|
+
synchronous=synchronous, broadcast_changes_at_date=broadcast_changes_at_date
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
def invalidate_future_order_proposal(self):
|
|
893
|
+
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
894
|
+
self.portfolio.order_proposals.filter(
|
|
895
|
+
trade_date__gt=self.trade_date, rebalancing_model__isnull=False, comment="Automatic rebalancing"
|
|
896
|
+
).delete()
|
|
897
|
+
for future_order_proposal in self.portfolio.order_proposals.filter(
|
|
898
|
+
trade_date__gt=self.trade_date, status=OrderProposal.Status.CONFIRMED
|
|
899
|
+
):
|
|
900
|
+
future_order_proposal.revert()
|
|
901
|
+
future_order_proposal.save()
|
|
902
|
+
|
|
903
|
+
def get_estimated_shares(
|
|
904
|
+
self, weight: Decimal, underlying_quote: Instrument, quote_price: Decimal
|
|
905
|
+
) -> Decimal | None:
|
|
906
|
+
"""
|
|
907
|
+
Estimates the number of shares for a order based on the given weight and underlying quote.
|
|
908
|
+
|
|
909
|
+
This method calculates the estimated shares by dividing the order's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
|
|
910
|
+
|
|
911
|
+
Args:
|
|
912
|
+
weight (Decimal): The weight of the order.
|
|
913
|
+
underlying_quote (Instrument): The underlying instrument for the order.
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
917
|
+
"""
|
|
918
|
+
# Retrieve the price of the underlying quote on the order date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
919
|
+
|
|
920
|
+
# if an order exists for this estimation and the target weight is 0, then we return the inverse of the effective shares
|
|
921
|
+
with suppress(Order.DoesNotExist):
|
|
922
|
+
order = self.get_orders().get(underlying_instrument=underlying_quote)
|
|
923
|
+
if order.target_weight == 0:
|
|
924
|
+
return -order.effective_shares
|
|
925
|
+
# Calculate the order's total value in the portfolio's currency
|
|
926
|
+
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
927
|
+
|
|
928
|
+
# Convert the quote price to the portfolio's currency
|
|
929
|
+
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
930
|
+
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
931
|
+
)
|
|
932
|
+
# If the price is valid, calculate and return the estimated shares
|
|
933
|
+
if price_fx_portfolio:
|
|
934
|
+
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
935
|
+
|
|
936
|
+
def get_round_lot_size(self, shares: Decimal, underlying_quote: Instrument) -> Decimal:
|
|
937
|
+
if (round_lot_size := underlying_quote.round_lot_size) != 1 and (
|
|
938
|
+
not underlying_quote.exchange or underlying_quote.exchange.apply_round_lot_size
|
|
939
|
+
):
|
|
940
|
+
if shares > 0:
|
|
941
|
+
shares = math.ceil(shares / round_lot_size) * round_lot_size
|
|
942
|
+
elif abs(shares) > round_lot_size:
|
|
943
|
+
shares = math.floor(shares / round_lot_size) * round_lot_size
|
|
944
|
+
return shares
|
|
945
|
+
|
|
946
|
+
def get_estimated_target_cash(self, target_cash_weight: Decimal | None = None) -> AssetPosition:
|
|
947
|
+
"""
|
|
948
|
+
Estimates the target cash weight and shares for a order proposal.
|
|
949
|
+
|
|
950
|
+
This method calculates the target cash weight by summing the weights of cash orders and adding any leftover weight from non-cash orders. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
target_cash_weight (Decimal): the expected target cash weight (Optional). If not provided, we estimate from the existing orders
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
957
|
+
"""
|
|
958
|
+
# Retrieve orders with base information
|
|
959
|
+
orders = self.get_orders()
|
|
960
|
+
# Calculate the total target weight of all orders
|
|
961
|
+
total_target_weight = orders.filter(
|
|
962
|
+
underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
|
|
963
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
964
|
+
if target_cash_weight is None:
|
|
965
|
+
target_cash_weight = Decimal("1") - total_target_weight
|
|
966
|
+
|
|
967
|
+
# Initialize target shares to zero
|
|
968
|
+
total_target_shares = Decimal(0)
|
|
969
|
+
|
|
970
|
+
# Get or create a cash component for the portfolio's currency
|
|
971
|
+
cash_component = self.cash_component
|
|
972
|
+
# If the portfolio is not only weighting-based, estimate the target shares for the cash component
|
|
973
|
+
if not self.portfolio.only_weighting:
|
|
974
|
+
# Estimate the target shares for the cash component
|
|
975
|
+
with suppress(ValueError):
|
|
976
|
+
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component, Decimal("1.0"))
|
|
977
|
+
|
|
978
|
+
# otherwise, we create a new position
|
|
979
|
+
underlying_quote_price = InstrumentPrice.objects.get_or_create(
|
|
980
|
+
instrument=cash_component,
|
|
981
|
+
date=self.trade_date,
|
|
982
|
+
calculated=False,
|
|
983
|
+
defaults={"net_value": Decimal(1)},
|
|
984
|
+
)[0]
|
|
985
|
+
return AssetPosition(
|
|
986
|
+
underlying_quote=cash_component,
|
|
987
|
+
portfolio_created=None,
|
|
988
|
+
portfolio=self.portfolio,
|
|
989
|
+
date=self.trade_date,
|
|
990
|
+
weighting=target_cash_weight,
|
|
991
|
+
initial_price=underlying_quote_price.net_value,
|
|
992
|
+
initial_shares=total_target_shares,
|
|
993
|
+
asset_valuation_date=self.trade_date,
|
|
994
|
+
underlying_quote_price=underlying_quote_price,
|
|
995
|
+
currency=cash_component.currency,
|
|
996
|
+
is_estimated=False,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# WORKFLOW METHODS
|
|
1000
|
+
@transition(
|
|
1001
|
+
field=status,
|
|
1002
|
+
source=Status.DRAFT,
|
|
1003
|
+
target=Status.PENDING,
|
|
1004
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1005
|
+
user.profile, portfolio=instance.portfolio
|
|
1006
|
+
),
|
|
1007
|
+
custom={
|
|
1008
|
+
"_transition_button": ActionButton(
|
|
1009
|
+
method=RequestType.PATCH,
|
|
1010
|
+
identifiers=("wbportfolio:order",),
|
|
1011
|
+
icon=WBIcon.SEND.icon,
|
|
1012
|
+
key="submit",
|
|
1013
|
+
label="Submit",
|
|
1014
|
+
action_label="Submit",
|
|
1015
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1016
|
+
)
|
|
1017
|
+
},
|
|
1018
|
+
)
|
|
1019
|
+
def submit(self, by=None, description=None, pretrade_check: bool = True, **kwargs):
|
|
1020
|
+
orders = []
|
|
1021
|
+
orders_validation_warnings = []
|
|
1022
|
+
qs = self.get_orders()
|
|
1023
|
+
for order in qs:
|
|
1024
|
+
order_warnings = order.submit(
|
|
1025
|
+
by=by, description=description, portfolio_total_asset_value=self.portfolio_total_asset_value, **kwargs
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
if order_warnings:
|
|
1029
|
+
orders_validation_warnings.extend(order_warnings)
|
|
1030
|
+
orders.append(order)
|
|
1031
|
+
|
|
1032
|
+
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
1033
|
+
if pretrade_check:
|
|
1034
|
+
self.evaluate_pretrade_checks()
|
|
1035
|
+
else:
|
|
1036
|
+
self.refresh_cash_position()
|
|
1037
|
+
return orders_validation_warnings
|
|
1038
|
+
|
|
1039
|
+
def can_submit(self):
|
|
1040
|
+
errors = dict()
|
|
1041
|
+
return errors
|
|
1042
|
+
|
|
1043
|
+
@transition(
|
|
1044
|
+
field=status,
|
|
1045
|
+
source=Status.PENDING,
|
|
1046
|
+
target=Status.APPROVED,
|
|
1047
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1048
|
+
user.profile, portfolio=instance.portfolio
|
|
1049
|
+
)
|
|
1050
|
+
and not instance.has_non_successful_checks,
|
|
1051
|
+
custom={
|
|
1052
|
+
"_transition_button": ActionButton(
|
|
1053
|
+
method=RequestType.PATCH,
|
|
1054
|
+
identifiers=("wbportfolio:order",),
|
|
1055
|
+
icon=WBIcon.APPROVE.icon,
|
|
1056
|
+
key="approve",
|
|
1057
|
+
label="Approve",
|
|
1058
|
+
action_label="Approve",
|
|
1059
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1060
|
+
)
|
|
1061
|
+
},
|
|
1062
|
+
)
|
|
1063
|
+
def approve(self, by=None, replay: bool = True, **kwargs):
|
|
1064
|
+
if by:
|
|
1065
|
+
self.approver = getattr(by, "profile", None)
|
|
1066
|
+
elif not self.approver:
|
|
1067
|
+
self.approver = self.creator
|
|
1068
|
+
if self.portfolio.can_be_rebalanced:
|
|
1069
|
+
self.apply()
|
|
1070
|
+
if replay:
|
|
1071
|
+
replay_as_task.apply_async(
|
|
1072
|
+
(self.id,),
|
|
1073
|
+
{
|
|
1074
|
+
"user_id": by.id if by else None,
|
|
1075
|
+
"broadcast_changes_at_date": False,
|
|
1076
|
+
"reapply_order_proposal": True,
|
|
1077
|
+
},
|
|
1078
|
+
countdown=10,
|
|
1079
|
+
)
|
|
1080
|
+
if by and self.custodian_router:
|
|
1081
|
+
for user in User.objects.exclude(id=by.id).filter(
|
|
1082
|
+
profile__in=PortfolioRole.portfolio_managers(), is_active=True
|
|
1083
|
+
):
|
|
1084
|
+
send_notification(
|
|
1085
|
+
code="wbportfolio.portfolio.action_done",
|
|
1086
|
+
title="An Order Proposal was approved and is waiting execution",
|
|
1087
|
+
body=f"The order proposal {self} has been approved by {by.profile.full_name} and is now pending execution. Please review the orders carefully and proceed with execution if appropriate.",
|
|
1088
|
+
user=user,
|
|
1089
|
+
reverse_name="wbportfolio:orderproposal-detail",
|
|
1090
|
+
reverse_args=[self.id],
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
@transition(
|
|
1094
|
+
field=status,
|
|
1095
|
+
source=Status.APPROVED,
|
|
1096
|
+
target=Status.CONFIRMED,
|
|
1097
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1098
|
+
user.profile, portfolio=instance.portfolio
|
|
1099
|
+
)
|
|
1100
|
+
and instance.portfolio.can_be_rebalanced,
|
|
1101
|
+
custom={
|
|
1102
|
+
"_transition_button": ActionButton(
|
|
1103
|
+
method=RequestType.PATCH,
|
|
1104
|
+
identifiers=("wbportfolio:order",),
|
|
1105
|
+
icon=WBIcon.LOCK.icon,
|
|
1106
|
+
key="confirm",
|
|
1107
|
+
label="Confirm",
|
|
1108
|
+
action_label="Lock order proposal",
|
|
1109
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1110
|
+
)
|
|
1111
|
+
},
|
|
1112
|
+
)
|
|
1113
|
+
def confirm(self, by=None, replay: bool = True, **kwargs):
|
|
1114
|
+
self.refresh_cash_position()
|
|
1115
|
+
if self.portfolio.can_be_rebalanced:
|
|
1116
|
+
self.apply()
|
|
1117
|
+
if replay:
|
|
1118
|
+
replay_as_task.apply_async(
|
|
1119
|
+
(self.id,),
|
|
1120
|
+
{
|
|
1121
|
+
"user_id": by.id if by else None,
|
|
1122
|
+
"broadcast_changes_at_date": False,
|
|
1123
|
+
"reapply_order_proposal": True,
|
|
1124
|
+
},
|
|
1125
|
+
countdown=10,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
@transition(
|
|
1129
|
+
field=status,
|
|
1130
|
+
source=Status.PENDING,
|
|
1131
|
+
target=Status.DENIED,
|
|
1132
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1133
|
+
user.profile, portfolio=instance.portfolio
|
|
1134
|
+
)
|
|
1135
|
+
and not instance.has_non_successful_checks,
|
|
1136
|
+
custom={
|
|
1137
|
+
"_transition_button": ActionButton(
|
|
1138
|
+
method=RequestType.PATCH,
|
|
1139
|
+
identifiers=("wbportfolio:order",),
|
|
1140
|
+
icon=WBIcon.DENY.icon,
|
|
1141
|
+
key="deny",
|
|
1142
|
+
label="Deny",
|
|
1143
|
+
action_label="Deny",
|
|
1144
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1145
|
+
)
|
|
1146
|
+
},
|
|
1147
|
+
)
|
|
1148
|
+
def deny(self, by=None, description=None, **kwargs):
|
|
1149
|
+
pass
|
|
1150
|
+
|
|
1151
|
+
def can_deny(self):
|
|
1152
|
+
pass
|
|
1153
|
+
|
|
1154
|
+
def apply(self):
|
|
1155
|
+
# We validate order which will create or update the initial asset positions
|
|
1156
|
+
if not self.portfolio.can_be_rebalanced:
|
|
1157
|
+
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
1158
|
+
|
|
1159
|
+
# We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
|
|
1160
|
+
target_portfolio = self.get_target_portfolio()
|
|
1161
|
+
assets = {i: float(pos.weighting) for i, pos in target_portfolio.positions_map.items()}
|
|
1162
|
+
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
1163
|
+
force_save=True, is_estimated=False, delete_leftovers=True
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
@transition(
|
|
1167
|
+
field=status,
|
|
1168
|
+
source=Status.PENDING,
|
|
1169
|
+
target=Status.DRAFT,
|
|
1170
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1171
|
+
user.profile, portfolio=instance.portfolio
|
|
1172
|
+
)
|
|
1173
|
+
and instance.has_all_check_completed
|
|
1174
|
+
or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
|
|
1175
|
+
custom={
|
|
1176
|
+
"_transition_button": ActionButton(
|
|
1177
|
+
method=RequestType.PATCH,
|
|
1178
|
+
identifiers=("wbportfolio:order",),
|
|
1179
|
+
icon=WBIcon.UNDO.icon,
|
|
1180
|
+
key="backtodraft",
|
|
1181
|
+
label="Back to Draft",
|
|
1182
|
+
action_label="backtodraft",
|
|
1183
|
+
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
1184
|
+
)
|
|
1185
|
+
},
|
|
1186
|
+
)
|
|
1187
|
+
def backtodraft(self, **kwargs):
|
|
1188
|
+
self.checks.delete()
|
|
1189
|
+
|
|
1190
|
+
def can_backtodraft(self):
|
|
1191
|
+
pass
|
|
1192
|
+
|
|
1193
|
+
@transition(
|
|
1194
|
+
field=status,
|
|
1195
|
+
source=Status.CONFIRMED,
|
|
1196
|
+
target=Status.DRAFT,
|
|
1197
|
+
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
1198
|
+
user.profile, portfolio=instance.portfolio
|
|
1199
|
+
),
|
|
1200
|
+
custom={
|
|
1201
|
+
"_transition_button": ActionButton(
|
|
1202
|
+
method=RequestType.PATCH,
|
|
1203
|
+
identifiers=("wbportfolio:order",),
|
|
1204
|
+
icon=WBIcon.REGENERATE.icon,
|
|
1205
|
+
key="revert",
|
|
1206
|
+
label="Revert",
|
|
1207
|
+
action_label="revert",
|
|
1208
|
+
description_fields="<p>Unapply orders and move everything back to draft (i.e. The underlying asset positions will change like the orders were never applied)</p>",
|
|
1209
|
+
)
|
|
1210
|
+
},
|
|
1211
|
+
)
|
|
1212
|
+
def revert(self, **kwargs):
|
|
1213
|
+
self.approver = None
|
|
1214
|
+
self.portfolio.assets.filter(date=self.trade_date, is_estimated=False).update(
|
|
1215
|
+
is_estimated=True
|
|
1216
|
+
) # we delete the existing portfolio as it has been reverted
|
|
1217
|
+
|
|
1218
|
+
def can_revert(self):
|
|
1219
|
+
errors = dict()
|
|
1220
|
+
if not self.portfolio.can_be_rebalanced:
|
|
1221
|
+
errors["portfolio"] = [
|
|
1222
|
+
gettext_lazy(
|
|
1223
|
+
"The portfolio needs to be a model portfolio in order to revert this order proposal manually"
|
|
1224
|
+
)
|
|
1225
|
+
]
|
|
1226
|
+
return errors
|
|
1227
|
+
|
|
1228
|
+
def apply_workflow(
|
|
1229
|
+
self,
|
|
1230
|
+
apply_automatically: bool = True,
|
|
1231
|
+
silent_exception: bool = False,
|
|
1232
|
+
force_reset_order: bool = False,
|
|
1233
|
+
**reset_order_kwargs,
|
|
1234
|
+
):
|
|
1235
|
+
# before, we need to save all positions in the builder first because effective weight depends on it
|
|
1236
|
+
self.portfolio.builder.bulk_create_positions(delete_leftovers=True)
|
|
1237
|
+
if self.status == OrderProposal.Status.CONFIRMED:
|
|
1238
|
+
logger.info("Reverting order proposal ...")
|
|
1239
|
+
self.revert()
|
|
1240
|
+
if self.status == OrderProposal.Status.DRAFT:
|
|
1241
|
+
if (
|
|
1242
|
+
self.rebalancing_model or force_reset_order
|
|
1243
|
+
): # if there is no position (for any reason) or we the order proposal has a rebalancer model attached (orders are computed based on an aglo), we reapply this order proposal
|
|
1244
|
+
logger.info("Resetting orders ...")
|
|
1245
|
+
try: # we silent any validation error while setting proposal, because if this happens, we assume the current order proposal state if valid and we continue to batch compute
|
|
1246
|
+
self.reset_orders(**reset_order_kwargs)
|
|
1247
|
+
except (ValidationError, DatabaseError) as e:
|
|
1248
|
+
self.status = OrderProposal.Status.FAILED
|
|
1249
|
+
if not silent_exception:
|
|
1250
|
+
raise ValidationError(e) from e
|
|
1251
|
+
return
|
|
1252
|
+
logger.info("Submitting order proposal ...")
|
|
1253
|
+
self.submit(pretrade_check=False)
|
|
1254
|
+
if apply_automatically:
|
|
1255
|
+
logger.info("Applying order proposal ...")
|
|
1256
|
+
if self.status == OrderProposal.Status.PENDING:
|
|
1257
|
+
self.approve(replay=False)
|
|
1258
|
+
else:
|
|
1259
|
+
self.apply()
|
|
1260
|
+
self.status = self.Status.CONFIRMED
|
|
1261
|
+
|
|
1262
|
+
# End FSM logics
|
|
1263
|
+
|
|
1264
|
+
@classmethod
|
|
1265
|
+
def get_endpoint_basename(cls) -> str:
|
|
1266
|
+
return "wbportfolio:orderproposal"
|
|
1267
|
+
|
|
1268
|
+
@classmethod
|
|
1269
|
+
def get_representation_endpoint(cls) -> str:
|
|
1270
|
+
return "wbportfolio:orderproposalrepresentation-list"
|
|
1271
|
+
|
|
1272
|
+
@classmethod
|
|
1273
|
+
def get_representation_value_key(cls) -> str:
|
|
1274
|
+
return "id"
|
|
1275
|
+
|
|
1276
|
+
@classmethod
|
|
1277
|
+
def get_representation_label_key(cls) -> str:
|
|
1278
|
+
return "{{_portfolio.name}} ({{trade_date}})"
|
|
1279
|
+
|
|
1280
|
+
@classmethod
|
|
1281
|
+
def build(
|
|
1282
|
+
cls,
|
|
1283
|
+
trade_date: date,
|
|
1284
|
+
portfolio,
|
|
1285
|
+
target_portfolio: PortfolioDTO,
|
|
1286
|
+
creator: User | None = None,
|
|
1287
|
+
approve_automatically: bool = True,
|
|
1288
|
+
) -> Self:
|
|
1289
|
+
order_proposal, _ = OrderProposal.objects.update_or_create(
|
|
1290
|
+
portfolio=portfolio,
|
|
1291
|
+
trade_date=trade_date,
|
|
1292
|
+
defaults={"status": OrderProposal.Status.DRAFT, "creator": creator.profile if creator else None},
|
|
1293
|
+
)
|
|
1294
|
+
order_proposal.reset_orders(target_portfolio=target_portfolio)
|
|
1295
|
+
if approve_automatically:
|
|
1296
|
+
order_proposal.submit()
|
|
1297
|
+
order_proposal.approve(by=creator)
|
|
1298
|
+
if portfolio.can_be_rebalanced:
|
|
1299
|
+
order_proposal.apply()
|
|
1300
|
+
order_proposal.save()
|
|
1301
|
+
return order_proposal
|
|
1302
|
+
|
|
1303
|
+
def push_to_dependant_portfolios(
|
|
1304
|
+
self, only_portfolios: QuerySet[Portfolio] | None = None, **build_kwargs
|
|
1305
|
+
) -> list[Self]:
|
|
1306
|
+
order_proposals = []
|
|
1307
|
+
for rel in self.portfolio.get_model_portfolio_relationships(self.trade_date):
|
|
1308
|
+
existing_order_proposal = OrderProposal.objects.filter(
|
|
1309
|
+
portfolio=rel.portfolio, trade_date=self.trade_date
|
|
1310
|
+
).first()
|
|
1311
|
+
# we allow push only on existing draft order proposal
|
|
1312
|
+
dependency_portfolio = rel.dependency_portfolio
|
|
1313
|
+
if (
|
|
1314
|
+
(only_portfolios is None or rel.portfolio in only_portfolios)
|
|
1315
|
+
and (not existing_order_proposal or existing_order_proposal.status == OrderProposal.Status.DRAFT)
|
|
1316
|
+
and dependency_portfolio.assets.filter(date=self.trade_date).exists()
|
|
1317
|
+
):
|
|
1318
|
+
target_portfolio = dependency_portfolio._build_dto(self.trade_date)
|
|
1319
|
+
order_proposals.append(
|
|
1320
|
+
OrderProposal.build(self.trade_date, rel.portfolio, target_portfolio, **build_kwargs)
|
|
1321
|
+
)
|
|
1322
|
+
return order_proposals
|
|
1323
|
+
|
|
1324
|
+
def evaluate_pretrade_checks(self, asynchronously: bool = True):
|
|
1325
|
+
self.checks.all().delete()
|
|
1326
|
+
self.refresh_cash_position()
|
|
1327
|
+
self.evaluate_active_rules(self.trade_date, self.get_target_portfolio(), asynchronously=asynchronously)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
@receiver(post_save, sender="wbportfolio.OrderProposal")
|
|
1331
|
+
def post_fail_order_proposal(sender, instance: OrderProposal, created, raw, **kwargs):
|
|
1332
|
+
# if we have a order proposal in a fail state, we ensure that all future existing order proposal are either deleted (automatic one) or set back to draft
|
|
1333
|
+
if not raw and instance.status == OrderProposal.Status.FAILED:
|
|
1334
|
+
# we delete all order proposal that have a rebalancing model and are marked as "automatic" (quite hardcoded yet)
|
|
1335
|
+
instance.invalidate_future_order_proposal()
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
@shared_task(queue="oms")
|
|
1339
|
+
def replay_as_task(order_proposal_id, user_id: int | None = None, **kwargs):
|
|
1340
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1341
|
+
order_proposal.replay(**kwargs)
|
|
1342
|
+
if user_id:
|
|
1343
|
+
body = f'We’ve successfully replayed your order proposal for "{order_proposal.portfolio}" from {order_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.'
|
|
1344
|
+
user = User.objects.get(id=user_id)
|
|
1345
|
+
if order_proposal.portfolio.builder.excluded_positions:
|
|
1346
|
+
excluded_quotes = []
|
|
1347
|
+
for batch in order_proposal.portfolio.builder.excluded_positions.values():
|
|
1348
|
+
for pos in batch:
|
|
1349
|
+
excluded_quotes.append(pos.underlying_instrument)
|
|
1350
|
+
body += "<p><strong>Note</strong></p><p>While replaying and drifting the portfolio, we excluded the positions from the following quotes because of missing price</p> <ul>"
|
|
1351
|
+
for excluded_quote in set(excluded_quotes):
|
|
1352
|
+
body += f"<li>{excluded_quote}</li>"
|
|
1353
|
+
body += "</ul>"
|
|
1354
|
+
order_proposal.portfolio.builder.clear()
|
|
1355
|
+
send_notification(
|
|
1356
|
+
code="wbportfolio.portfolio.action_done",
|
|
1357
|
+
title="Order Proposal Replay Completed",
|
|
1358
|
+
body=body,
|
|
1359
|
+
user=user,
|
|
1360
|
+
reverse_name="wbportfolio:portfolio-detail",
|
|
1361
|
+
reverse_args=[order_proposal.portfolio.id],
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@shared_task(queue="oms")
|
|
1366
|
+
def execute_orders_as_task(order_proposal_id: int, prioritize_target_weight: bool = False, **kwargs):
|
|
1367
|
+
order_proposal = OrderProposal.objects.get(id=order_proposal_id)
|
|
1368
|
+
order_proposal.execute_orders()
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
@shared_task(queue="oms")
|
|
1372
|
+
def push_model_change_as_task(
|
|
1373
|
+
model_order_proposal_id: int,
|
|
1374
|
+
user_id: int | None = None,
|
|
1375
|
+
only_for_portfolio_ids: list[int] | None = None,
|
|
1376
|
+
approve_automatically: bool = False,
|
|
1377
|
+
):
|
|
1378
|
+
# not happy with that but we will keep it for the MVP lifecycle
|
|
1379
|
+
model_order_proposal = OrderProposal.objects.get(id=model_order_proposal_id)
|
|
1380
|
+
user = User.objects.get(id=user_id) if user_id else None
|
|
1381
|
+
params = dict(approve_automatically=approve_automatically, creator=user)
|
|
1382
|
+
only_portfolios = None
|
|
1383
|
+
if only_for_portfolio_ids:
|
|
1384
|
+
only_portfolios = Portfolio.objects.filter(id__in=only_for_portfolio_ids)
|
|
1385
|
+
|
|
1386
|
+
order_proposals = model_order_proposal.push_to_dependant_portfolios(only_portfolios=only_portfolios, **params)
|
|
1387
|
+
product_html_list = "<ul>\n"
|
|
1388
|
+
for order_proposal in order_proposals:
|
|
1389
|
+
product_html_list += f"<li>{order_proposal.portfolio}</li>\n"
|
|
1390
|
+
|
|
1391
|
+
product_html_list += "</ul>"
|
|
1392
|
+
if user:
|
|
1393
|
+
send_notification(
|
|
1394
|
+
code="wbportfolio.order_proposal.push_model_changes",
|
|
1395
|
+
title="Portfolio Model changes are pushed to dependant portfolios",
|
|
1396
|
+
body=f"""
|
|
1397
|
+
<p>The latest updates to the portfolio model <strong>{model_order_proposal.portfolio}</strong> have been successfully applied to the associated portfolios, and corresponding orders have been created.</p>
|
|
1398
|
+
<p>To proceed with executing these orders, please review the following related portfolios: </p>
|
|
1399
|
+
{product_html_list}
|
|
1400
|
+
""",
|
|
1401
|
+
user=user,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
@receiver(investable_universe_updated, sender="wbfdm.Instrument")
|
|
1406
|
+
def update_exante_order_proposal_returns(*args, end_date: date | None = None, **kwargs):
|
|
1407
|
+
for op in OrderProposal.objects.filter(trade_date__gte=end_date):
|
|
1408
|
+
op.refresh_returns()
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
@receiver(pre_delete, sender=OrderProposal)
|
|
1412
|
+
def post_delete_adjustment(sender, instance: OrderProposal, **kwargs):
|
|
1413
|
+
for check in instance.checks.all():
|
|
1414
|
+
check.delete()
|