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
|
@@ -1,704 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from contextlib import suppress
|
|
3
|
-
from datetime import date, timedelta
|
|
4
|
-
from decimal import Decimal
|
|
5
|
-
from typing import TypeVar
|
|
6
|
-
|
|
7
|
-
from celery import shared_task
|
|
8
|
-
from django.core.exceptions import ValidationError
|
|
9
|
-
from django.db import models
|
|
10
|
-
from django.utils.functional import cached_property
|
|
11
|
-
from django.utils.translation import gettext_lazy as _
|
|
12
|
-
from django_fsm import FSMField, transition
|
|
13
|
-
from pandas._libs.tslibs.offsets import BDay
|
|
14
|
-
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
15
|
-
from wbcore.contrib.authentication.models import User
|
|
16
|
-
from wbcore.contrib.currency.models import Currency
|
|
17
|
-
from wbcore.contrib.icons import WBIcon
|
|
18
|
-
from wbcore.contrib.notifications.dispatch import send_notification
|
|
19
|
-
from wbcore.enums import RequestType
|
|
20
|
-
from wbcore.metadata.configs.buttons import ActionButton
|
|
21
|
-
from wbcore.models import WBModel
|
|
22
|
-
from wbcore.utils.models import CloneMixin
|
|
23
|
-
from wbfdm.models import InstrumentPrice
|
|
24
|
-
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
25
|
-
|
|
26
|
-
from wbportfolio.models.roles import PortfolioRole
|
|
27
|
-
from wbportfolio.pms.trading import TradingService
|
|
28
|
-
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
29
|
-
from wbportfolio.pms.typing import TradeBatch as TradeBatchDTO
|
|
30
|
-
|
|
31
|
-
from ..asset import AssetPosition, AssetPositionIterator
|
|
32
|
-
from .trades import Trade
|
|
33
|
-
|
|
34
|
-
logger = logging.getLogger("pms")
|
|
35
|
-
|
|
36
|
-
SelfTradeProposal = TypeVar("SelfTradeProposal", bound="TradeProposal")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
40
|
-
trade_date = models.DateField(verbose_name="Trading Date")
|
|
41
|
-
|
|
42
|
-
class Status(models.TextChoices):
|
|
43
|
-
DRAFT = "DRAFT", "Draft"
|
|
44
|
-
SUBMIT = "SUBMIT", "Submit"
|
|
45
|
-
APPROVED = "APPROVED", "Approved"
|
|
46
|
-
DENIED = "DENIED", "Denied"
|
|
47
|
-
FAILED = "FAILED", "Failed"
|
|
48
|
-
|
|
49
|
-
comment = models.TextField(default="", verbose_name="Trade Comment", blank=True)
|
|
50
|
-
status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name="Status")
|
|
51
|
-
rebalancing_model = models.ForeignKey(
|
|
52
|
-
"wbportfolio.RebalancingModel",
|
|
53
|
-
on_delete=models.SET_NULL,
|
|
54
|
-
blank=True,
|
|
55
|
-
null=True,
|
|
56
|
-
related_name="trade_proposals",
|
|
57
|
-
verbose_name="Rebalancing Model",
|
|
58
|
-
help_text="Rebalancing Model that generates the target portfolio",
|
|
59
|
-
)
|
|
60
|
-
portfolio = models.ForeignKey(
|
|
61
|
-
"wbportfolio.Portfolio", related_name="trade_proposals", on_delete=models.PROTECT, verbose_name="Portfolio"
|
|
62
|
-
)
|
|
63
|
-
creator = models.ForeignKey(
|
|
64
|
-
"directory.Person",
|
|
65
|
-
blank=True,
|
|
66
|
-
null=True,
|
|
67
|
-
related_name="trade_proposals",
|
|
68
|
-
on_delete=models.PROTECT,
|
|
69
|
-
verbose_name="Owner",
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
class Meta:
|
|
73
|
-
verbose_name = "Trade Proposal"
|
|
74
|
-
verbose_name_plural = "Trade Proposals"
|
|
75
|
-
constraints = [
|
|
76
|
-
models.UniqueConstraint(
|
|
77
|
-
fields=["portfolio", "trade_date"],
|
|
78
|
-
name="unique_trade_proposal",
|
|
79
|
-
),
|
|
80
|
-
]
|
|
81
|
-
|
|
82
|
-
def save(self, *args, **kwargs):
|
|
83
|
-
if not self.trade_date and self.portfolio.assets.exists():
|
|
84
|
-
self.trade_date = (self.portfolio.assets.latest("date").date + BDay(1)).date()
|
|
85
|
-
|
|
86
|
-
# if a trade proposal is created before the existing earliest trade proposal, we automatically shift the linked instruments inception date to allow automatic NAV computation since the new inception date
|
|
87
|
-
if not self.portfolio.trade_proposals.filter(trade_date__lt=self.trade_date).exists():
|
|
88
|
-
new_inception_date = (self.trade_date + BDay(1)).date()
|
|
89
|
-
self.portfolio.instruments.filter(inception_date__gt=new_inception_date).update(
|
|
90
|
-
inception_date=new_inception_date
|
|
91
|
-
)
|
|
92
|
-
super().save(*args, **kwargs)
|
|
93
|
-
|
|
94
|
-
@property
|
|
95
|
-
def checked_object(self):
|
|
96
|
-
return self.portfolio
|
|
97
|
-
|
|
98
|
-
@property
|
|
99
|
-
def check_evaluation_date(self):
|
|
100
|
-
return self.trade_date
|
|
101
|
-
|
|
102
|
-
@cached_property
|
|
103
|
-
def portfolio_total_asset_value(self) -> Decimal:
|
|
104
|
-
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
105
|
-
|
|
106
|
-
@cached_property
|
|
107
|
-
def validated_trading_service(self) -> TradingService:
|
|
108
|
-
"""
|
|
109
|
-
This property holds the validated trading services and cache it.This property expect to be set only if is_valid return True
|
|
110
|
-
"""
|
|
111
|
-
return TradingService(
|
|
112
|
-
self.trade_date,
|
|
113
|
-
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
114
|
-
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
@cached_property
|
|
118
|
-
def last_effective_date(self) -> date:
|
|
119
|
-
try:
|
|
120
|
-
return self.portfolio.assets.filter(date__lt=self.trade_date).latest("date").date
|
|
121
|
-
except AssetPosition.DoesNotExist:
|
|
122
|
-
return (self.trade_date - BDay(1)).date()
|
|
123
|
-
|
|
124
|
-
@property
|
|
125
|
-
def previous_trade_proposal(self) -> SelfTradeProposal | None:
|
|
126
|
-
future_proposals = TradeProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
127
|
-
trade_date__lt=self.trade_date, status=TradeProposal.Status.APPROVED
|
|
128
|
-
)
|
|
129
|
-
if future_proposals.exists():
|
|
130
|
-
return future_proposals.latest("trade_date")
|
|
131
|
-
return None
|
|
132
|
-
|
|
133
|
-
@property
|
|
134
|
-
def next_trade_proposal(self) -> SelfTradeProposal | None:
|
|
135
|
-
future_proposals = TradeProposal.objects.filter(portfolio=self.portfolio).filter(
|
|
136
|
-
trade_date__gt=self.trade_date, status=TradeProposal.Status.APPROVED
|
|
137
|
-
)
|
|
138
|
-
if future_proposals.exists():
|
|
139
|
-
return future_proposals.earliest("trade_date")
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
@property
|
|
143
|
-
def base_assets(self) -> dict[int, Decimal]:
|
|
144
|
-
"""
|
|
145
|
-
Return a dictionary representation (instrument_id: target weight) of this trade proposal
|
|
146
|
-
Returns:
|
|
147
|
-
A dictionary representation
|
|
148
|
-
|
|
149
|
-
"""
|
|
150
|
-
return {
|
|
151
|
-
v["underlying_instrument"]: v["target_weight"]
|
|
152
|
-
for v in self.trades.all()
|
|
153
|
-
.annotate_base_info()
|
|
154
|
-
.filter(status=Trade.Status.EXECUTED)
|
|
155
|
-
.values("underlying_instrument", "target_weight")
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
def __str__(self) -> str:
|
|
159
|
-
return f"{self.portfolio.name}: {self.trade_date} ({self.status})"
|
|
160
|
-
|
|
161
|
-
def _build_dto(self) -> TradeBatchDTO:
|
|
162
|
-
"""
|
|
163
|
-
Data Transfer Object
|
|
164
|
-
Returns:
|
|
165
|
-
DTO trade object
|
|
166
|
-
"""
|
|
167
|
-
return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
|
|
168
|
-
|
|
169
|
-
# Start tools methods
|
|
170
|
-
def _clone(self, **kwargs) -> SelfTradeProposal:
|
|
171
|
-
"""
|
|
172
|
-
Method to clone self as a new trade proposal. It will automatically shift the trade date if a proposal already exists
|
|
173
|
-
Args:
|
|
174
|
-
**kwargs: The keyword arguments
|
|
175
|
-
Returns:
|
|
176
|
-
The cloned trade proposal
|
|
177
|
-
"""
|
|
178
|
-
trade_date = kwargs.get("clone_date", self.trade_date)
|
|
179
|
-
|
|
180
|
-
# Find the next valid trade date
|
|
181
|
-
while TradeProposal.objects.filter(portfolio=self.portfolio, trade_date=trade_date).exists():
|
|
182
|
-
trade_date += timedelta(days=1)
|
|
183
|
-
|
|
184
|
-
trade_proposal_clone = TradeProposal.objects.create(
|
|
185
|
-
trade_date=trade_date,
|
|
186
|
-
comment=kwargs.get("clone_comment", self.comment),
|
|
187
|
-
status=TradeProposal.Status.DRAFT,
|
|
188
|
-
rebalancing_model=self.rebalancing_model,
|
|
189
|
-
portfolio=self.portfolio,
|
|
190
|
-
creator=self.creator,
|
|
191
|
-
)
|
|
192
|
-
for trade in self.trades.all():
|
|
193
|
-
trade.id = None
|
|
194
|
-
trade.trade_proposal = trade_proposal_clone
|
|
195
|
-
trade.save()
|
|
196
|
-
|
|
197
|
-
return trade_proposal_clone
|
|
198
|
-
|
|
199
|
-
def normalize_trades(self):
|
|
200
|
-
"""
|
|
201
|
-
Call the trading service with the existing trades and normalize them in order to obtain a total sum target weight of 100%
|
|
202
|
-
The existing trade will be modified directly with the given normalization factor
|
|
203
|
-
"""
|
|
204
|
-
service = TradingService(self.trade_date, trades_batch=self._build_dto())
|
|
205
|
-
service.normalize()
|
|
206
|
-
leftovers_trades = self.trades.all()
|
|
207
|
-
total_target_weight = Decimal("0.0")
|
|
208
|
-
for underlying_instrument_id, trade_dto in service.trades_batch.trades_map.items():
|
|
209
|
-
with suppress(Trade.DoesNotExist):
|
|
210
|
-
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
211
|
-
trade.weighting = round(trade_dto.delta_weight, 6)
|
|
212
|
-
trade.save()
|
|
213
|
-
total_target_weight += trade._target_weight
|
|
214
|
-
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
215
|
-
leftovers_trades.delete()
|
|
216
|
-
# we handle quantization error due to the decimal max digits. In that case, we take the biggest trade (highest weight) and we remove the quantization error
|
|
217
|
-
if quantize_error := (total_target_weight - Decimal("1.0")):
|
|
218
|
-
biggest_trade = self.trades.latest("weighting")
|
|
219
|
-
biggest_trade.weighting -= quantize_error
|
|
220
|
-
biggest_trade.save()
|
|
221
|
-
|
|
222
|
-
def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
|
|
223
|
-
if self.rebalancing_model:
|
|
224
|
-
params = {}
|
|
225
|
-
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
226
|
-
params.update(rebalancer.parameters)
|
|
227
|
-
params.update(kwargs)
|
|
228
|
-
return self.rebalancing_model.get_target_portfolio(
|
|
229
|
-
self.portfolio, self.trade_date, self.last_effective_date, **params
|
|
230
|
-
)
|
|
231
|
-
if self.trades.exists():
|
|
232
|
-
return self._build_dto().convert_to_portfolio()
|
|
233
|
-
# Return the current portfolio by default
|
|
234
|
-
return self.portfolio._build_dto(self.last_effective_date)
|
|
235
|
-
|
|
236
|
-
def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
|
|
237
|
-
"""
|
|
238
|
-
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
239
|
-
"""
|
|
240
|
-
# delete all existing trades
|
|
241
|
-
last_effective_date = self.last_effective_date
|
|
242
|
-
# Get effective and target portfolio
|
|
243
|
-
effective_portfolio = self.portfolio._build_dto(last_effective_date)
|
|
244
|
-
if not target_portfolio:
|
|
245
|
-
target_portfolio = self._get_default_target_portfolio()
|
|
246
|
-
|
|
247
|
-
if target_portfolio:
|
|
248
|
-
service = TradingService(
|
|
249
|
-
self.trade_date,
|
|
250
|
-
effective_portfolio=effective_portfolio,
|
|
251
|
-
target_portfolio=target_portfolio,
|
|
252
|
-
)
|
|
253
|
-
if validate_trade:
|
|
254
|
-
service.normalize()
|
|
255
|
-
service.is_valid()
|
|
256
|
-
trades = service.validated_trades
|
|
257
|
-
else:
|
|
258
|
-
trades = service.trades_batch.trades_map.values()
|
|
259
|
-
for trade_dto in trades:
|
|
260
|
-
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
261
|
-
currency_fx_rate = instrument.currency.convert(
|
|
262
|
-
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
263
|
-
)
|
|
264
|
-
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
265
|
-
weighting = round(trade_dto.delta_weight, 6)
|
|
266
|
-
try:
|
|
267
|
-
trade = self.trades.get(underlying_instrument=instrument)
|
|
268
|
-
trade.weighting = weighting
|
|
269
|
-
trade.currency_fx_rate = currency_fx_rate
|
|
270
|
-
trade.status = Trade.Status.DRAFT
|
|
271
|
-
except Trade.DoesNotExist:
|
|
272
|
-
trade = Trade(
|
|
273
|
-
underlying_instrument=instrument,
|
|
274
|
-
currency=instrument.currency,
|
|
275
|
-
value_date=last_effective_date,
|
|
276
|
-
transaction_date=self.trade_date,
|
|
277
|
-
trade_proposal=self,
|
|
278
|
-
portfolio=self.portfolio,
|
|
279
|
-
weighting=weighting,
|
|
280
|
-
status=Trade.Status.DRAFT,
|
|
281
|
-
currency_fx_rate=currency_fx_rate,
|
|
282
|
-
)
|
|
283
|
-
trade.save()
|
|
284
|
-
|
|
285
|
-
def replay(self):
|
|
286
|
-
last_trade_proposal = self
|
|
287
|
-
last_trade_proposal_created = False
|
|
288
|
-
while last_trade_proposal and last_trade_proposal.status == TradeProposal.Status.APPROVED:
|
|
289
|
-
if not last_trade_proposal_created:
|
|
290
|
-
logger.info(f"Replaying trade proposal {last_trade_proposal}")
|
|
291
|
-
last_trade_proposal.portfolio.assets.filter(
|
|
292
|
-
date=last_trade_proposal.trade_date
|
|
293
|
-
).delete() # we delete the existing position and we reapply the trade proposal
|
|
294
|
-
if last_trade_proposal.status == TradeProposal.Status.APPROVED:
|
|
295
|
-
logger.info("Reverting trade proposal ...")
|
|
296
|
-
last_trade_proposal.revert()
|
|
297
|
-
if last_trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
298
|
-
if self.rebalancing_model: # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
|
|
299
|
-
logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
|
|
300
|
-
with suppress(
|
|
301
|
-
ValidationError
|
|
302
|
-
): # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
|
|
303
|
-
self.reset_trades()
|
|
304
|
-
logger.info("Submitting trade proposal ...")
|
|
305
|
-
last_trade_proposal.submit()
|
|
306
|
-
if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
|
|
307
|
-
logger.info("Approving trade proposal ...")
|
|
308
|
-
last_trade_proposal.approve(replay=False)
|
|
309
|
-
last_trade_proposal.save()
|
|
310
|
-
next_trade_proposal = last_trade_proposal.next_trade_proposal
|
|
311
|
-
|
|
312
|
-
next_trade_date = (
|
|
313
|
-
next_trade_proposal.trade_date - timedelta(days=1) if next_trade_proposal else date.today()
|
|
314
|
-
)
|
|
315
|
-
positions, overriding_trade_proposal = self.portfolio.drift_weights(
|
|
316
|
-
last_trade_proposal.trade_date, next_trade_date
|
|
317
|
-
)
|
|
318
|
-
self.portfolio.assets.filter(
|
|
319
|
-
date__gt=last_trade_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
|
|
320
|
-
).update(
|
|
321
|
-
is_estimated=True
|
|
322
|
-
) # ensure that we reset non estimated position leftover to estimated between trade proposal during replay
|
|
323
|
-
self.portfolio.bulk_create_positions(
|
|
324
|
-
positions, delete_leftovers=True, compute_metrics=False, evaluate_rebalancer=False
|
|
325
|
-
)
|
|
326
|
-
if overriding_trade_proposal:
|
|
327
|
-
last_trade_proposal_created = True
|
|
328
|
-
last_trade_proposal = overriding_trade_proposal
|
|
329
|
-
else:
|
|
330
|
-
last_trade_proposal_created = False
|
|
331
|
-
last_trade_proposal = next_trade_proposal
|
|
332
|
-
|
|
333
|
-
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal:
|
|
334
|
-
"""
|
|
335
|
-
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
336
|
-
|
|
337
|
-
This method calculates the estimated shares by dividing the trade'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.
|
|
338
|
-
|
|
339
|
-
Args:
|
|
340
|
-
weight (Decimal): The weight of the trade.
|
|
341
|
-
underlying_quote (Instrument): The underlying instrument for the trade.
|
|
342
|
-
|
|
343
|
-
Returns:
|
|
344
|
-
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
345
|
-
"""
|
|
346
|
-
try:
|
|
347
|
-
# Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
348
|
-
quote_price = Decimal(underlying_quote.get_price(self.trade_date))
|
|
349
|
-
|
|
350
|
-
# Calculate the trade's total value in the portfolio's currency
|
|
351
|
-
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
352
|
-
|
|
353
|
-
# Convert the quote price to the portfolio's currency
|
|
354
|
-
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
355
|
-
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
# If the price is valid, calculate and return the estimated shares
|
|
359
|
-
if price_fx_portfolio:
|
|
360
|
-
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
361
|
-
except Exception:
|
|
362
|
-
raise ValueError("We couldn't estimate the number of shares")
|
|
363
|
-
|
|
364
|
-
def get_estimated_target_cash(self, currency: Currency) -> AssetPosition:
|
|
365
|
-
"""
|
|
366
|
-
Estimates the target cash weight and shares for a trade proposal.
|
|
367
|
-
|
|
368
|
-
This method calculates the target cash weight by summing the weights of cash trades and adding any leftover weight from non-cash trades. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
|
|
369
|
-
|
|
370
|
-
Args:
|
|
371
|
-
currency (Currency): The currency for the target currency component
|
|
372
|
-
|
|
373
|
-
Returns:
|
|
374
|
-
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
375
|
-
"""
|
|
376
|
-
# Retrieve trades with base information
|
|
377
|
-
trades = self.trades.all().annotate_base_info()
|
|
378
|
-
|
|
379
|
-
# Calculate the target cash weight from cash trades
|
|
380
|
-
target_cash_weight = trades.filter(
|
|
381
|
-
underlying_instrument__is_cash=True, underlying_instrument__currency=currency
|
|
382
|
-
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
383
|
-
# if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
|
|
384
|
-
if currency == self.portfolio.currency:
|
|
385
|
-
# Calculate the total target weight of all trades
|
|
386
|
-
total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
387
|
-
|
|
388
|
-
# Add any leftover weight as cash
|
|
389
|
-
target_cash_weight += Decimal(1) - total_target_weight
|
|
390
|
-
|
|
391
|
-
# Initialize target shares to zero
|
|
392
|
-
total_target_shares = Decimal(0)
|
|
393
|
-
|
|
394
|
-
# If the portfolio is not only weighting-based, estimate the target shares for the cash component
|
|
395
|
-
if not self.portfolio.only_weighting:
|
|
396
|
-
# Get or create a cash component for the portfolio's currency
|
|
397
|
-
cash_component = Cash.objects.get_or_create(
|
|
398
|
-
currency=currency, defaults={"is_cash": True, "name": currency.title}
|
|
399
|
-
)[0]
|
|
400
|
-
|
|
401
|
-
# Estimate the target shares for the cash component
|
|
402
|
-
with suppress(ValueError):
|
|
403
|
-
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
|
|
404
|
-
|
|
405
|
-
cash_component = Cash.objects.get_or_create(
|
|
406
|
-
currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
|
|
407
|
-
)[0]
|
|
408
|
-
# otherwise, we create a new position
|
|
409
|
-
underlying_quote_price = InstrumentPrice.objects.get_or_create(
|
|
410
|
-
instrument=cash_component,
|
|
411
|
-
date=self.trade_date,
|
|
412
|
-
calculated=False,
|
|
413
|
-
defaults={"net_value": Decimal(1)},
|
|
414
|
-
)[0]
|
|
415
|
-
return AssetPosition(
|
|
416
|
-
underlying_quote=cash_component,
|
|
417
|
-
portfolio_created=None,
|
|
418
|
-
portfolio=self.portfolio,
|
|
419
|
-
date=self.trade_date,
|
|
420
|
-
weighting=target_cash_weight,
|
|
421
|
-
initial_price=underlying_quote_price.net_value,
|
|
422
|
-
initial_shares=total_target_shares,
|
|
423
|
-
asset_valuation_date=self.trade_date,
|
|
424
|
-
underlying_quote_price=underlying_quote_price,
|
|
425
|
-
currency=cash_component.currency,
|
|
426
|
-
is_estimated=False,
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
# Start FSM logics
|
|
430
|
-
|
|
431
|
-
@transition(
|
|
432
|
-
field=status,
|
|
433
|
-
source=Status.DRAFT,
|
|
434
|
-
target=Status.SUBMIT,
|
|
435
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
436
|
-
user.profile, portfolio=instance.portfolio
|
|
437
|
-
),
|
|
438
|
-
custom={
|
|
439
|
-
"_transition_button": ActionButton(
|
|
440
|
-
method=RequestType.PATCH,
|
|
441
|
-
identifiers=("wbportfolio:tradeproposal",),
|
|
442
|
-
icon=WBIcon.SEND.icon,
|
|
443
|
-
key="submit",
|
|
444
|
-
label="Submit",
|
|
445
|
-
action_label="Submit",
|
|
446
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
447
|
-
)
|
|
448
|
-
},
|
|
449
|
-
)
|
|
450
|
-
def submit(self, by=None, description=None, **kwargs):
|
|
451
|
-
self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
|
|
452
|
-
trades = []
|
|
453
|
-
for trade in self.trades.all():
|
|
454
|
-
trade.status = Trade.Status.SUBMIT
|
|
455
|
-
trade.comment = ""
|
|
456
|
-
trades.append(trade)
|
|
457
|
-
|
|
458
|
-
Trade.objects.bulk_update(trades, ["status", "comment"])
|
|
459
|
-
|
|
460
|
-
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
461
|
-
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
462
|
-
target_portfolio = self.validated_trading_service.target_portfolio
|
|
463
|
-
if estimated_cash_position.weighting:
|
|
464
|
-
if existing_cash_position := target_portfolio.positions_map.get(
|
|
465
|
-
estimated_cash_position.underlying_quote.id
|
|
466
|
-
):
|
|
467
|
-
existing_cash_position += estimated_cash_position
|
|
468
|
-
else:
|
|
469
|
-
target_portfolio.positions_map[estimated_cash_position.underlying_quote.id] = (
|
|
470
|
-
estimated_cash_position._build_dto()
|
|
471
|
-
)
|
|
472
|
-
target_portfolio = PortfolioDTO(positions=tuple(target_portfolio.positions_map.values()))
|
|
473
|
-
|
|
474
|
-
self.evaluate_active_rules(self.trade_date, target_portfolio, asynchronously=True)
|
|
475
|
-
|
|
476
|
-
def can_submit(self):
|
|
477
|
-
errors = dict()
|
|
478
|
-
errors_list = []
|
|
479
|
-
if self.trades.exists() and self.trades.exclude(status=Trade.Status.DRAFT).exists():
|
|
480
|
-
errors_list.append(_("All trades need to be draft before submitting"))
|
|
481
|
-
service = self.validated_trading_service
|
|
482
|
-
try:
|
|
483
|
-
service.is_valid(ignore_error=True)
|
|
484
|
-
# if service.trades_batch.totat_abs_delta_weight == 0:
|
|
485
|
-
# errors_list.append(
|
|
486
|
-
# "There is no change detected in this trade proposal. Please submit at last one valid trade"
|
|
487
|
-
# )
|
|
488
|
-
if len(service.validated_trades) == 0:
|
|
489
|
-
errors_list.append(_("There is no valid trade on this proposal"))
|
|
490
|
-
if service.errors:
|
|
491
|
-
errors_list.extend(service.errors)
|
|
492
|
-
if errors_list:
|
|
493
|
-
errors["non_field_errors"] = errors_list
|
|
494
|
-
except ValidationError:
|
|
495
|
-
errors["non_field_errors"] = service.errors
|
|
496
|
-
with suppress(KeyError):
|
|
497
|
-
del self.__dict__["validated_trading_service"]
|
|
498
|
-
return errors
|
|
499
|
-
|
|
500
|
-
@property
|
|
501
|
-
def can_be_approved_or_denied(self):
|
|
502
|
-
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
503
|
-
|
|
504
|
-
@transition(
|
|
505
|
-
field=status,
|
|
506
|
-
source=Status.SUBMIT,
|
|
507
|
-
target=Status.APPROVED,
|
|
508
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
509
|
-
user.profile, portfolio=instance.portfolio
|
|
510
|
-
)
|
|
511
|
-
and instance.can_be_approved_or_denied,
|
|
512
|
-
custom={
|
|
513
|
-
"_transition_button": ActionButton(
|
|
514
|
-
method=RequestType.PATCH,
|
|
515
|
-
identifiers=("wbportfolio:tradeproposal",),
|
|
516
|
-
icon=WBIcon.APPROVE.icon,
|
|
517
|
-
key="approve",
|
|
518
|
-
label="Approve",
|
|
519
|
-
action_label="Approve",
|
|
520
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
521
|
-
)
|
|
522
|
-
},
|
|
523
|
-
)
|
|
524
|
-
def approve(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
525
|
-
# We validate trade which will create or update the initial asset positions
|
|
526
|
-
if not self.portfolio.can_be_rebalanced:
|
|
527
|
-
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
528
|
-
trades = []
|
|
529
|
-
assets = []
|
|
530
|
-
# We do not want to create the estimated cash position if there is not trades in the trade proposal (shouldn't be possible anyway)
|
|
531
|
-
estimated_cash_position = self.get_estimated_target_cash(self.portfolio.currency)
|
|
532
|
-
|
|
533
|
-
for trade in self.trades.all():
|
|
534
|
-
with suppress(ValueError):
|
|
535
|
-
asset = trade.get_asset()
|
|
536
|
-
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
537
|
-
if asset.underlying_quote != estimated_cash_position.underlying_quote:
|
|
538
|
-
assets.append(asset)
|
|
539
|
-
trade.status = Trade.Status.EXECUTED
|
|
540
|
-
trades.append(trade)
|
|
541
|
-
|
|
542
|
-
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
543
|
-
if estimated_cash_position.weighting and len(trades) > 0:
|
|
544
|
-
estimated_cash_position.pre_save()
|
|
545
|
-
assets.append(estimated_cash_position)
|
|
546
|
-
|
|
547
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
548
|
-
self.portfolio.bulk_create_positions(
|
|
549
|
-
AssetPositionIterator(self.portfolio).add(assets), evaluate_rebalancer=False, force_save=True
|
|
550
|
-
)
|
|
551
|
-
if replay and self.portfolio.is_manageable:
|
|
552
|
-
replay_as_task.delay(self.id, user_id=by.id if by else None)
|
|
553
|
-
|
|
554
|
-
def can_approve(self):
|
|
555
|
-
errors = dict()
|
|
556
|
-
if not self.portfolio.can_be_rebalanced:
|
|
557
|
-
errors["non_field_errors"] = [_("The portfolio does not allow manual rebalanced")]
|
|
558
|
-
if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
|
|
559
|
-
errors["non_field_errors"] = [
|
|
560
|
-
_("At least one trade needs to be submitted to be able to approve this proposal")
|
|
561
|
-
]
|
|
562
|
-
if not self.portfolio.can_be_rebalanced:
|
|
563
|
-
errors["portfolio"] = [
|
|
564
|
-
[_("The portfolio needs to be a model portfolio in order to approve this trade proposal manually")]
|
|
565
|
-
]
|
|
566
|
-
if self.has_non_successful_checks:
|
|
567
|
-
errors["non_field_errors"] = [_("The pre trades rules did not passed successfully")]
|
|
568
|
-
return errors
|
|
569
|
-
|
|
570
|
-
@transition(
|
|
571
|
-
field=status,
|
|
572
|
-
source=Status.SUBMIT,
|
|
573
|
-
target=Status.DENIED,
|
|
574
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
575
|
-
user.profile, portfolio=instance.portfolio
|
|
576
|
-
)
|
|
577
|
-
and instance.can_be_approved_or_denied,
|
|
578
|
-
custom={
|
|
579
|
-
"_transition_button": ActionButton(
|
|
580
|
-
method=RequestType.PATCH,
|
|
581
|
-
identifiers=("wbportfolio:tradeproposal",),
|
|
582
|
-
icon=WBIcon.DENY.icon,
|
|
583
|
-
key="deny",
|
|
584
|
-
label="Deny",
|
|
585
|
-
action_label="Deny",
|
|
586
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
587
|
-
)
|
|
588
|
-
},
|
|
589
|
-
)
|
|
590
|
-
def deny(self, by=None, description=None, **kwargs):
|
|
591
|
-
self.trades.all().delete()
|
|
592
|
-
with suppress(KeyError):
|
|
593
|
-
del self.__dict__["validated_trading_service"]
|
|
594
|
-
|
|
595
|
-
def can_deny(self):
|
|
596
|
-
errors = dict()
|
|
597
|
-
if self.trades.exclude(status=Trade.Status.SUBMIT).exists():
|
|
598
|
-
errors["non_field_errors"] = [
|
|
599
|
-
_("At least one trade needs to be submitted to be able to deny this proposal")
|
|
600
|
-
]
|
|
601
|
-
return errors
|
|
602
|
-
|
|
603
|
-
@transition(
|
|
604
|
-
field=status,
|
|
605
|
-
source=Status.SUBMIT,
|
|
606
|
-
target=Status.DRAFT,
|
|
607
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
608
|
-
user.profile, portfolio=instance.portfolio
|
|
609
|
-
)
|
|
610
|
-
and instance.has_all_check_completed
|
|
611
|
-
or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
|
|
612
|
-
custom={
|
|
613
|
-
"_transition_button": ActionButton(
|
|
614
|
-
method=RequestType.PATCH,
|
|
615
|
-
identifiers=("wbportfolio:tradeproposal",),
|
|
616
|
-
icon=WBIcon.UNDO.icon,
|
|
617
|
-
key="backtodraft",
|
|
618
|
-
label="Back to Draft",
|
|
619
|
-
action_label="backtodraft",
|
|
620
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
621
|
-
)
|
|
622
|
-
},
|
|
623
|
-
)
|
|
624
|
-
def backtodraft(self, **kwargs):
|
|
625
|
-
with suppress(KeyError):
|
|
626
|
-
del self.__dict__["validated_trading_service"]
|
|
627
|
-
self.trades.update(status=Trade.Status.DRAFT)
|
|
628
|
-
self.checks.delete()
|
|
629
|
-
|
|
630
|
-
def can_backtodraft(self):
|
|
631
|
-
pass
|
|
632
|
-
|
|
633
|
-
@transition(
|
|
634
|
-
field=status,
|
|
635
|
-
source=Status.APPROVED,
|
|
636
|
-
target=Status.DRAFT,
|
|
637
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
638
|
-
user.profile, portfolio=instance.portfolio
|
|
639
|
-
),
|
|
640
|
-
custom={
|
|
641
|
-
"_transition_button": ActionButton(
|
|
642
|
-
method=RequestType.PATCH,
|
|
643
|
-
identifiers=("wbportfolio:tradeproposal",),
|
|
644
|
-
icon=WBIcon.REGENERATE.icon,
|
|
645
|
-
key="revert",
|
|
646
|
-
label="Revert",
|
|
647
|
-
action_label="revert",
|
|
648
|
-
description_fields="<p>Unapply trades and move everything back to draft (i.e. The underlying asset positions will change like the trades were never applied)</p>",
|
|
649
|
-
)
|
|
650
|
-
},
|
|
651
|
-
)
|
|
652
|
-
def revert(self, **kwargs):
|
|
653
|
-
with suppress(KeyError):
|
|
654
|
-
del self.__dict__["validated_trading_service"]
|
|
655
|
-
trades = []
|
|
656
|
-
self.portfolio.assets.filter(
|
|
657
|
-
date=self.trade_date, is_estimated=False
|
|
658
|
-
).delete() # we delete the existing portfolio as it has been reverted
|
|
659
|
-
for trade in self.trades.all():
|
|
660
|
-
trade.status = Trade.Status.DRAFT
|
|
661
|
-
trades.append(trade)
|
|
662
|
-
Trade.objects.bulk_update(trades, ["status"])
|
|
663
|
-
|
|
664
|
-
def can_revert(self):
|
|
665
|
-
errors = dict()
|
|
666
|
-
if not self.portfolio.can_be_rebalanced:
|
|
667
|
-
errors["portfolio"] = [
|
|
668
|
-
_("The portfolio needs to be a model portfolio in order to revert this trade proposal manually")
|
|
669
|
-
]
|
|
670
|
-
return errors
|
|
671
|
-
|
|
672
|
-
# End FSM logics
|
|
673
|
-
|
|
674
|
-
@classmethod
|
|
675
|
-
def get_endpoint_basename(cls) -> str:
|
|
676
|
-
return "wbportfolio:tradeproposal"
|
|
677
|
-
|
|
678
|
-
@classmethod
|
|
679
|
-
def get_representation_endpoint(cls) -> str:
|
|
680
|
-
return "wbportfolio:tradeproposalrepresentation-list"
|
|
681
|
-
|
|
682
|
-
@classmethod
|
|
683
|
-
def get_representation_value_key(cls) -> str:
|
|
684
|
-
return "id"
|
|
685
|
-
|
|
686
|
-
@classmethod
|
|
687
|
-
def get_representation_label_key(cls) -> str:
|
|
688
|
-
return "{{_portfolio.name}} ({{trade_date}})"
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
@shared_task(queue="portfolio")
|
|
692
|
-
def replay_as_task(trade_proposal_id, user_id: int | None = None):
|
|
693
|
-
trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
|
|
694
|
-
trade_proposal.replay()
|
|
695
|
-
if user_id:
|
|
696
|
-
user = User.objects.get(id=user_id)
|
|
697
|
-
send_notification(
|
|
698
|
-
code="wbportfolio.portfolio.replay_done",
|
|
699
|
-
title="Trade Proposal Replay Completed",
|
|
700
|
-
body=f'We’ve successfully replayed your trade proposal for "{trade_proposal.portfolio}" from {trade_proposal.trade_date:%Y-%m-%d}. You can now review its updated composition.',
|
|
701
|
-
user=user,
|
|
702
|
-
reverse_name="wbportfolio:portfolio-detail",
|
|
703
|
-
reverse_args=[trade_proposal.portfolio.id],
|
|
704
|
-
)
|