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,1046 @@
|
|
|
1
|
+
# Import necessary modules
|
|
2
|
+
from datetime import date, timedelta
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from unittest.mock import MagicMock, PropertyMock, call, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from django.db.models import Sum
|
|
8
|
+
from faker import Faker
|
|
9
|
+
from pandas._libs.tslibs.offsets import BDay, BusinessMonthEnd
|
|
10
|
+
|
|
11
|
+
from wbportfolio.models import Order, OrderProposal, Portfolio, RebalancingModel
|
|
12
|
+
from wbportfolio.order_routing import ExecutionInstruction, ExecutionStatus, RoutingException
|
|
13
|
+
from wbportfolio.pms.typing import Order as OrderDTO
|
|
14
|
+
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
15
|
+
from wbportfolio.pms.typing import Position
|
|
16
|
+
|
|
17
|
+
fake = Faker()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def mock_adapter():
|
|
22
|
+
adapter = MagicMock()
|
|
23
|
+
return adapter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Mark tests to use Django's database
|
|
27
|
+
@pytest.mark.django_db
|
|
28
|
+
class TestOrderProposal:
|
|
29
|
+
def test_init(self, order_proposal):
|
|
30
|
+
assert order_proposal.id is not None
|
|
31
|
+
|
|
32
|
+
# Test that the checked object is correctly set to the portfolio
|
|
33
|
+
def test_checked_object(self, order_proposal):
|
|
34
|
+
"""
|
|
35
|
+
Verify that the checked object is the portfolio associated with the order proposal.
|
|
36
|
+
"""
|
|
37
|
+
assert order_proposal.checked_object == order_proposal.portfolio
|
|
38
|
+
|
|
39
|
+
# Test that the evaluation date matches the trade date
|
|
40
|
+
def test_check_evaluation_date(self, order_proposal):
|
|
41
|
+
"""
|
|
42
|
+
Ensure the evaluation date is the same as the trade date.
|
|
43
|
+
"""
|
|
44
|
+
assert order_proposal.check_evaluation_date == order_proposal.trade_date
|
|
45
|
+
|
|
46
|
+
# Test the validated trading service functionality
|
|
47
|
+
def test_validated_trading_service(
|
|
48
|
+
self, order_proposal, asset_position_factory, instrument_price_factory, instrument_factory, order_factory
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Validate that the effective and target portfolios are correctly calculated.
|
|
52
|
+
"""
|
|
53
|
+
effective_date = (order_proposal.trade_date - BDay(1)).date()
|
|
54
|
+
|
|
55
|
+
i1 = instrument_factory.create()
|
|
56
|
+
i2 = instrument_factory.create()
|
|
57
|
+
|
|
58
|
+
p10 = instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
59
|
+
p11 = instrument_price_factory.create(instrument=i1, date=order_proposal.trade_date)
|
|
60
|
+
|
|
61
|
+
p20 = instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
62
|
+
p21 = instrument_price_factory.create(instrument=i2, date=order_proposal.trade_date)
|
|
63
|
+
|
|
64
|
+
# Create asset positions for testing
|
|
65
|
+
a1 = asset_position_factory.create(
|
|
66
|
+
portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.3"), underlying_instrument=i1
|
|
67
|
+
)
|
|
68
|
+
a2 = asset_position_factory.create(
|
|
69
|
+
portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.7"), underlying_instrument=i2
|
|
70
|
+
)
|
|
71
|
+
r1 = p11.net_value / p10.net_value - Decimal("1")
|
|
72
|
+
r2 = p21.net_value / p20.net_value - Decimal("1")
|
|
73
|
+
p_return = a1.weighting * (Decimal("1") + r1) + a2.weighting * (Decimal("1") + r2)
|
|
74
|
+
order_proposal.total_effective_portfolio_contribution = p_return
|
|
75
|
+
order_proposal.save()
|
|
76
|
+
|
|
77
|
+
# Create orders for testing
|
|
78
|
+
o1 = order_factory.create(
|
|
79
|
+
order_proposal=order_proposal,
|
|
80
|
+
weighting=Decimal("0.05"),
|
|
81
|
+
portfolio=order_proposal.portfolio,
|
|
82
|
+
underlying_instrument=i1,
|
|
83
|
+
)
|
|
84
|
+
o2 = order_factory.create(
|
|
85
|
+
order_proposal=order_proposal,
|
|
86
|
+
weighting=Decimal("-0.05"),
|
|
87
|
+
portfolio=order_proposal.portfolio,
|
|
88
|
+
underlying_instrument=i2,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Get the validated trading service
|
|
92
|
+
trades = order_proposal.get_trades_batch().trades_map
|
|
93
|
+
t1 = trades[a1.underlying_quote.id]
|
|
94
|
+
t2 = trades[a2.underlying_quote.id]
|
|
95
|
+
|
|
96
|
+
# Assert effective and target portfolios are as expected
|
|
97
|
+
assert t1.effective_weight == pytest.approx(
|
|
98
|
+
a1.weighting * ((r1 + Decimal("1")) / p_return), abs=Decimal("1e-8")
|
|
99
|
+
)
|
|
100
|
+
assert t2.effective_weight == pytest.approx(
|
|
101
|
+
a2.weighting * ((r2 + Decimal("1")) / p_return), abs=Decimal("1e-8")
|
|
102
|
+
)
|
|
103
|
+
assert t1.target_weight == pytest.approx(
|
|
104
|
+
a1.weighting * ((r1 + Decimal("1")) / p_return) + o1.weighting, abs=Decimal("1e-8")
|
|
105
|
+
)
|
|
106
|
+
assert t2.target_weight == pytest.approx(
|
|
107
|
+
a2.weighting * ((r2 + Decimal("1")) / p_return) + o2.weighting, abs=Decimal("1e-8")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Test the calculation of the last effective date
|
|
111
|
+
def test_last_effective_date(self, order_proposal, asset_position_factory):
|
|
112
|
+
"""
|
|
113
|
+
Verify the last effective date is correctly determined based on asset positions.
|
|
114
|
+
"""
|
|
115
|
+
# Without any positions, it should be the day before the trade date
|
|
116
|
+
assert (
|
|
117
|
+
order_proposal.last_effective_date == (order_proposal.trade_date - BDay(1)).date()
|
|
118
|
+
), "Last effective date without position should be t-1"
|
|
119
|
+
|
|
120
|
+
# Create an asset position before the trade date
|
|
121
|
+
a1 = asset_position_factory.create(
|
|
122
|
+
portfolio=order_proposal.portfolio, date=(order_proposal.trade_date - BDay(5)).date()
|
|
123
|
+
)
|
|
124
|
+
a_noise = asset_position_factory.create(portfolio=order_proposal.portfolio, date=order_proposal.trade_date) # noqa
|
|
125
|
+
|
|
126
|
+
# The last effective date should still be the day before the trade date due to caching
|
|
127
|
+
assert (
|
|
128
|
+
order_proposal.last_effective_date == (order_proposal.trade_date - BDay(1)).date()
|
|
129
|
+
), "last effective date is cached, so it won't change as is"
|
|
130
|
+
|
|
131
|
+
# Reset the cache property to recalculate
|
|
132
|
+
del order_proposal.last_effective_date
|
|
133
|
+
|
|
134
|
+
# Now it should be the date of the latest position before the trade date
|
|
135
|
+
assert (
|
|
136
|
+
order_proposal.last_effective_date == a1.date
|
|
137
|
+
), "last effective date is the latest position strictly lower than trade date"
|
|
138
|
+
|
|
139
|
+
# Test finding the previous order proposal
|
|
140
|
+
def test_previous_order_proposal(self, order_proposal_factory):
|
|
141
|
+
"""
|
|
142
|
+
Ensure the previous order proposal is correctly identified as the last approved proposal before the current one.
|
|
143
|
+
"""
|
|
144
|
+
tp = order_proposal_factory.create()
|
|
145
|
+
tp_previous_submit = order_proposal_factory.create( # noqa
|
|
146
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date - BDay(1)).date()
|
|
147
|
+
)
|
|
148
|
+
tp_previous_approve = order_proposal_factory.create(
|
|
149
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date - BDay(2)).date()
|
|
150
|
+
)
|
|
151
|
+
tp_next_approve = order_proposal_factory.create( # noqa
|
|
152
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date + BDay(1)).date()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# The previous valid order proposal should be the approved one strictly before the current proposal
|
|
156
|
+
assert (
|
|
157
|
+
tp.previous_order_proposal == tp_previous_approve
|
|
158
|
+
), "the previous valid order proposal is the strictly before and approved order proposal"
|
|
159
|
+
|
|
160
|
+
# Test finding the next order proposal
|
|
161
|
+
def test_next_order_proposal(self, order_proposal_factory):
|
|
162
|
+
"""
|
|
163
|
+
Verify the next order proposal is correctly identified as the first approved proposal after the current one.
|
|
164
|
+
"""
|
|
165
|
+
tp = order_proposal_factory.create()
|
|
166
|
+
tp_previous_approve = order_proposal_factory.create( # noqa
|
|
167
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date - BDay(1)).date()
|
|
168
|
+
)
|
|
169
|
+
tp_next_submit = order_proposal_factory.create( # noqa
|
|
170
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date + BDay(1)).date()
|
|
171
|
+
)
|
|
172
|
+
tp_next_approve = order_proposal_factory.create(
|
|
173
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date + BDay(2)).date()
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# The next valid order proposal should be the approved one strictly after the current proposal
|
|
177
|
+
assert (
|
|
178
|
+
tp.next_order_proposal == tp_next_approve
|
|
179
|
+
), "the next valid order proposal is the strictly after and approved order proposal"
|
|
180
|
+
|
|
181
|
+
# Test getting the default target portfolio
|
|
182
|
+
def test__get_default_target_portfolio(self, order_proposal, asset_position_factory):
|
|
183
|
+
"""
|
|
184
|
+
Ensure the default target portfolio is set to the effective portfolio from the day before the trade date.
|
|
185
|
+
"""
|
|
186
|
+
effective_date = (order_proposal.trade_date - BDay(1)).date()
|
|
187
|
+
|
|
188
|
+
# Create asset positions for testing
|
|
189
|
+
a1 = asset_position_factory.create(
|
|
190
|
+
portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.3")
|
|
191
|
+
)
|
|
192
|
+
a2 = asset_position_factory.create(
|
|
193
|
+
portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.7")
|
|
194
|
+
)
|
|
195
|
+
asset_position_factory.create(portfolio=order_proposal.portfolio, date=order_proposal.trade_date) # noise
|
|
196
|
+
|
|
197
|
+
# The default target portfolio should match the effective portfolio
|
|
198
|
+
assert order_proposal._get_default_target_portfolio().to_dict() == {
|
|
199
|
+
a1.underlying_quote.id: a1.weighting,
|
|
200
|
+
a2.underlying_quote.id: a2.weighting,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Test getting the default target portfolio with a rebalancing model
|
|
204
|
+
@patch.object(RebalancingModel, "get_target_portfolio")
|
|
205
|
+
def test__get_default_target_portfolio_with_rebalancer_model(self, mock_fct, order_proposal, rebalancer_factory):
|
|
206
|
+
"""
|
|
207
|
+
Verify that the target portfolio is correctly obtained from a rebalancing model.
|
|
208
|
+
"""
|
|
209
|
+
# Expected target portfolio from the rebalancing model
|
|
210
|
+
expected_target_portfolio = PortfolioDTO(
|
|
211
|
+
positions=(Position(underlying_instrument=1, weighting=Decimal(1), date=order_proposal.trade_date),)
|
|
212
|
+
)
|
|
213
|
+
mock_fct.return_value = expected_target_portfolio
|
|
214
|
+
|
|
215
|
+
# Create a rebalancer for testing
|
|
216
|
+
rebalancer = rebalancer_factory.create(
|
|
217
|
+
portfolio=order_proposal.portfolio, parameters={"rebalancer_parameter": "A"}
|
|
218
|
+
)
|
|
219
|
+
order_proposal.rebalancing_model = rebalancer.rebalancing_model
|
|
220
|
+
order_proposal.save()
|
|
221
|
+
|
|
222
|
+
# Additional keyword arguments for the rebalancing model
|
|
223
|
+
extra_kwargs = {"test": "test"}
|
|
224
|
+
|
|
225
|
+
# Combine rebalancer parameters with extra keyword arguments
|
|
226
|
+
expected_kwargs = rebalancer.parameters
|
|
227
|
+
expected_kwargs.update(extra_kwargs)
|
|
228
|
+
|
|
229
|
+
# Assert the target portfolio matches the expected output from the rebalancing model
|
|
230
|
+
assert (
|
|
231
|
+
order_proposal._get_default_target_portfolio(**extra_kwargs) == expected_target_portfolio
|
|
232
|
+
), "We expect the target portfolio to be whatever is returned by the rebalancer model"
|
|
233
|
+
mock_fct.assert_called_once_with(
|
|
234
|
+
order_proposal.portfolio, order_proposal.trade_date, order_proposal.last_effective_date, **expected_kwargs
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Test normalizing orders
|
|
238
|
+
def test_normalize_orders(self, order_proposal, order_factory):
|
|
239
|
+
"""
|
|
240
|
+
Ensure orders are normalized to sum up to 1, handling quantization errors.
|
|
241
|
+
"""
|
|
242
|
+
# Create orders for testing
|
|
243
|
+
t1 = order_factory.create(
|
|
244
|
+
order_proposal=order_proposal,
|
|
245
|
+
portfolio=order_proposal.portfolio,
|
|
246
|
+
weighting=Decimal(0.05),
|
|
247
|
+
)
|
|
248
|
+
t2 = order_factory.create(
|
|
249
|
+
order_proposal=order_proposal,
|
|
250
|
+
portfolio=order_proposal.portfolio,
|
|
251
|
+
weighting=Decimal(0.22),
|
|
252
|
+
)
|
|
253
|
+
t3 = order_factory.create(
|
|
254
|
+
order_proposal=order_proposal,
|
|
255
|
+
portfolio=order_proposal.portfolio,
|
|
256
|
+
weighting=Decimal(0.14),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Normalize orders
|
|
260
|
+
order_proposal.normalize_orders(Decimal("0.18"))
|
|
261
|
+
|
|
262
|
+
# Refresh orders from the database
|
|
263
|
+
t1.refresh_from_db()
|
|
264
|
+
t2.refresh_from_db()
|
|
265
|
+
t3.refresh_from_db()
|
|
266
|
+
cash = order_proposal.orders.get(underlying_instrument__is_cash=True)
|
|
267
|
+
|
|
268
|
+
# Expected normalized weights
|
|
269
|
+
assert t1.weighting == Decimal("0.10")
|
|
270
|
+
assert t2.weighting == Decimal("0.44")
|
|
271
|
+
assert t3.weighting == Decimal("0.28")
|
|
272
|
+
assert cash.weighting == Decimal("0.18")
|
|
273
|
+
|
|
274
|
+
# Test resetting orders
|
|
275
|
+
def test_reset_orders(
|
|
276
|
+
self, order_proposal, instrument_factory, cash_factory, instrument_price_factory, asset_position_factory
|
|
277
|
+
):
|
|
278
|
+
"""
|
|
279
|
+
Verify orders are correctly reset based on effective and target portfolios.
|
|
280
|
+
"""
|
|
281
|
+
cash = cash_factory.create()
|
|
282
|
+
effective_date = order_proposal.last_effective_date
|
|
283
|
+
|
|
284
|
+
# Create instruments for testing
|
|
285
|
+
i1 = instrument_factory.create(currency=order_proposal.portfolio.currency)
|
|
286
|
+
i2 = instrument_factory.create(currency=order_proposal.portfolio.currency)
|
|
287
|
+
i3 = instrument_factory.create(currency=order_proposal.portfolio.currency)
|
|
288
|
+
# Build initial effective portfolio constituting only from two positions of i1 and i2
|
|
289
|
+
asset_position_factory.create(
|
|
290
|
+
portfolio=order_proposal.portfolio, date=effective_date, underlying_instrument=i1, weighting=Decimal("0.7")
|
|
291
|
+
)
|
|
292
|
+
asset_position_factory.create(
|
|
293
|
+
portfolio=order_proposal.portfolio, date=effective_date, underlying_instrument=i2, weighting=Decimal("0.3")
|
|
294
|
+
)
|
|
295
|
+
p1 = instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
296
|
+
p2 = instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
297
|
+
p3 = instrument_price_factory.create(instrument=i3, date=effective_date)
|
|
298
|
+
|
|
299
|
+
# build the target portfolio
|
|
300
|
+
target_portfolio = PortfolioDTO(
|
|
301
|
+
[
|
|
302
|
+
Position(
|
|
303
|
+
underlying_instrument=i2.id,
|
|
304
|
+
date=order_proposal.trade_date,
|
|
305
|
+
weighting=Decimal("0.4"),
|
|
306
|
+
price=float(p2.net_value),
|
|
307
|
+
),
|
|
308
|
+
Position(
|
|
309
|
+
underlying_instrument=i3.id,
|
|
310
|
+
date=order_proposal.trade_date,
|
|
311
|
+
weighting=Decimal("0.6"),
|
|
312
|
+
price=float(p3.net_value),
|
|
313
|
+
),
|
|
314
|
+
]
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Reset orders
|
|
318
|
+
order_proposal.reset_orders(target_portfolio=target_portfolio)
|
|
319
|
+
|
|
320
|
+
# Get orders for each instrument
|
|
321
|
+
t1 = order_proposal.orders.get(underlying_instrument=i1)
|
|
322
|
+
t2 = order_proposal.orders.get(underlying_instrument=i2)
|
|
323
|
+
t3 = order_proposal.orders.get(underlying_instrument=i3)
|
|
324
|
+
|
|
325
|
+
# Assert trade weights are correctly reset
|
|
326
|
+
assert t1.weighting == Decimal("-0.7")
|
|
327
|
+
assert t2.weighting == Decimal("0.1")
|
|
328
|
+
assert t3.weighting == Decimal("0.6")
|
|
329
|
+
|
|
330
|
+
# build the target portfolio
|
|
331
|
+
new_target_portfolio = PortfolioDTO(
|
|
332
|
+
[
|
|
333
|
+
Position(
|
|
334
|
+
underlying_instrument=i1.id,
|
|
335
|
+
date=order_proposal.trade_date,
|
|
336
|
+
weighting=Decimal("0.2"),
|
|
337
|
+
price=float(p1.net_value),
|
|
338
|
+
),
|
|
339
|
+
Position(
|
|
340
|
+
underlying_instrument=i2.id,
|
|
341
|
+
date=order_proposal.trade_date,
|
|
342
|
+
weighting=Decimal("0.3"),
|
|
343
|
+
price=float(p2.net_value),
|
|
344
|
+
),
|
|
345
|
+
Position(
|
|
346
|
+
underlying_instrument=i3.id,
|
|
347
|
+
date=order_proposal.trade_date,
|
|
348
|
+
weighting=Decimal("0.5"),
|
|
349
|
+
price=float(p3.net_value),
|
|
350
|
+
),
|
|
351
|
+
]
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
order_proposal.reset_orders(target_portfolio=new_target_portfolio)
|
|
355
|
+
# Refetch the orders for each instrument
|
|
356
|
+
t1.refresh_from_db()
|
|
357
|
+
t2.refresh_from_db()
|
|
358
|
+
t3.refresh_from_db()
|
|
359
|
+
# Assert existing trade weights are correctly updated
|
|
360
|
+
assert t1.weighting == Decimal("-0.5")
|
|
361
|
+
assert t2.weighting == Decimal("0")
|
|
362
|
+
assert t3.weighting == Decimal("0.5")
|
|
363
|
+
|
|
364
|
+
# assert cash position creates a proper order
|
|
365
|
+
# build the target portfolio
|
|
366
|
+
target_portfolio_with_cash = PortfolioDTO(
|
|
367
|
+
[
|
|
368
|
+
Position(
|
|
369
|
+
underlying_instrument=i1.id,
|
|
370
|
+
date=order_proposal.trade_date,
|
|
371
|
+
weighting=Decimal("0.5"),
|
|
372
|
+
price=float(p1.net_value),
|
|
373
|
+
),
|
|
374
|
+
Position(
|
|
375
|
+
underlying_instrument=cash.id,
|
|
376
|
+
date=order_proposal.trade_date,
|
|
377
|
+
weighting=Decimal("0.5"),
|
|
378
|
+
price=1.0,
|
|
379
|
+
),
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
order_proposal.reset_orders(target_portfolio=target_portfolio_with_cash)
|
|
383
|
+
|
|
384
|
+
# Assert existing trade weights are correctly updated
|
|
385
|
+
assert order_proposal.orders.get(underlying_instrument=i1).weighting == Decimal("-0.2")
|
|
386
|
+
assert order_proposal.orders.get(underlying_instrument=i2).weighting == Decimal("-0.3")
|
|
387
|
+
assert order_proposal.orders.get(underlying_instrument=cash).weighting == Decimal("0.5")
|
|
388
|
+
|
|
389
|
+
def test_reset_orders_remove_invalid_orders(self, order_proposal, order_factory):
|
|
390
|
+
# create a invalid trade and its price
|
|
391
|
+
invalid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(0))
|
|
392
|
+
|
|
393
|
+
# create a valid trade and its price
|
|
394
|
+
valid_trade = order_factory.create(order_proposal=order_proposal, weighting=Decimal(1))
|
|
395
|
+
order_proposal.reset_orders()
|
|
396
|
+
assert order_proposal.orders.get(underlying_instrument=valid_trade.underlying_instrument).weighting == Decimal(
|
|
397
|
+
"1"
|
|
398
|
+
)
|
|
399
|
+
with pytest.raises(Order.DoesNotExist):
|
|
400
|
+
order_proposal.orders.get(underlying_instrument=invalid_trade.underlying_instrument)
|
|
401
|
+
|
|
402
|
+
# Test replaying order proposals
|
|
403
|
+
@patch.object(Portfolio, "drift_weights")
|
|
404
|
+
def test_replay(self, mock_fct, order_proposal_factory):
|
|
405
|
+
"""
|
|
406
|
+
Ensure replaying order proposals correctly calls drift_weights for each period.
|
|
407
|
+
"""
|
|
408
|
+
mock_fct.return_value = iter([])
|
|
409
|
+
|
|
410
|
+
# Create approved order proposals for testing
|
|
411
|
+
tp0 = order_proposal_factory.create(status=OrderProposal.Status.CONFIRMED)
|
|
412
|
+
tp1 = order_proposal_factory.create(
|
|
413
|
+
portfolio=tp0.portfolio,
|
|
414
|
+
status=OrderProposal.Status.CONFIRMED,
|
|
415
|
+
trade_date=(tp0.trade_date + BusinessMonthEnd(1)).date(),
|
|
416
|
+
)
|
|
417
|
+
tp2 = order_proposal_factory.create(
|
|
418
|
+
portfolio=tp0.portfolio,
|
|
419
|
+
status=OrderProposal.Status.CONFIRMED,
|
|
420
|
+
trade_date=(tp1.trade_date + BusinessMonthEnd(1)).date(),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Replay order proposals
|
|
424
|
+
tp0.replay()
|
|
425
|
+
|
|
426
|
+
# Expected calls to drift_weights
|
|
427
|
+
expected_calls = [
|
|
428
|
+
call(tp0.trade_date, tp1.trade_date - timedelta(days=1), stop_at_rebalancing=True),
|
|
429
|
+
call(tp1.trade_date, tp2.trade_date - timedelta(days=1), stop_at_rebalancing=True),
|
|
430
|
+
call(tp2.trade_date, date.today(), stop_at_rebalancing=True),
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
# Assert drift_weights was called as expected
|
|
434
|
+
mock_fct.assert_has_calls(expected_calls)
|
|
435
|
+
|
|
436
|
+
# Test stopping replay on a non-approved proposal
|
|
437
|
+
tp1.status = OrderProposal.Status.FAILED
|
|
438
|
+
tp1.save()
|
|
439
|
+
expected_calls = [call(tp0.trade_date, tp1.trade_date - timedelta(days=1), stop_at_rebalancing=True)]
|
|
440
|
+
mock_fct.assert_has_calls(expected_calls)
|
|
441
|
+
|
|
442
|
+
# Test estimating shares for a trade
|
|
443
|
+
@patch.object(OrderProposal, "get_portfolio_total_asset_value")
|
|
444
|
+
def test_get_estimated_shares(
|
|
445
|
+
self, mock_fct, order_proposal, order_factory, instrument_price_factory, instrument_factory
|
|
446
|
+
):
|
|
447
|
+
"""
|
|
448
|
+
Verify shares estimation based on trade weighting and instrument price.
|
|
449
|
+
"""
|
|
450
|
+
portfolio = order_proposal.portfolio
|
|
451
|
+
instrument = instrument_factory.create(currency=portfolio.currency)
|
|
452
|
+
underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=order_proposal.trade_date)
|
|
453
|
+
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
454
|
+
trade = order_factory.create(
|
|
455
|
+
order_proposal=order_proposal,
|
|
456
|
+
value_date=order_proposal.trade_date,
|
|
457
|
+
portfolio=portfolio,
|
|
458
|
+
underlying_instrument=instrument,
|
|
459
|
+
)
|
|
460
|
+
trade.refresh_from_db()
|
|
461
|
+
|
|
462
|
+
# Assert estimated shares are correctly calculated
|
|
463
|
+
assert (
|
|
464
|
+
order_proposal.get_estimated_shares(
|
|
465
|
+
trade.weighting, trade.underlying_instrument, underlying_quote_price.net_value
|
|
466
|
+
)
|
|
467
|
+
== Decimal(1_000_000) * trade.weighting / underlying_quote_price.net_value
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
@patch.object(OrderProposal, "get_portfolio_total_asset_value")
|
|
471
|
+
def test_get_estimated_target_cash(self, mock_fct, order_proposal, order_factory, cash_factory):
|
|
472
|
+
order_proposal.portfolio.only_weighting = False
|
|
473
|
+
order_proposal.portfolio.save()
|
|
474
|
+
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
475
|
+
cash = cash_factory.create(currency=order_proposal.portfolio.currency)
|
|
476
|
+
order_factory.create( # equity trade
|
|
477
|
+
order_proposal=order_proposal,
|
|
478
|
+
value_date=order_proposal.trade_date,
|
|
479
|
+
portfolio=order_proposal.portfolio,
|
|
480
|
+
weighting=Decimal("0.7"),
|
|
481
|
+
)
|
|
482
|
+
order_factory.create( # cash trade
|
|
483
|
+
order_proposal=order_proposal,
|
|
484
|
+
value_date=order_proposal.trade_date,
|
|
485
|
+
portfolio=order_proposal.portfolio,
|
|
486
|
+
underlying_instrument=cash,
|
|
487
|
+
weighting=Decimal("0.2"),
|
|
488
|
+
)
|
|
489
|
+
target_cash_position = order_proposal.get_estimated_target_cash()
|
|
490
|
+
assert target_cash_position.weighting == Decimal("0.3")
|
|
491
|
+
assert target_cash_position.initial_shares == Decimal(1_000_000) * Decimal("0.3")
|
|
492
|
+
|
|
493
|
+
def test_order_proposal_update_inception_date(self, order_proposal_factory, portfolio, instrument_factory):
|
|
494
|
+
# Check that if we create a prior order proposal, the instrument inception date is updated accordingly
|
|
495
|
+
instrument = instrument_factory.create(inception_date=None)
|
|
496
|
+
instrument.portfolios.add(portfolio)
|
|
497
|
+
tp = order_proposal_factory.create(portfolio=portfolio)
|
|
498
|
+
instrument.refresh_from_db()
|
|
499
|
+
assert instrument.inception_date == tp.trade_date
|
|
500
|
+
|
|
501
|
+
tp2 = order_proposal_factory.create(portfolio=portfolio, trade_date=(tp.trade_date - BDay(1)).date())
|
|
502
|
+
instrument.refresh_from_db()
|
|
503
|
+
assert instrument.inception_date == tp2.trade_date
|
|
504
|
+
|
|
505
|
+
def test_get_round_lot_size(self, order_proposal, instrument):
|
|
506
|
+
# without a round lot size, we expect no normalization of shares
|
|
507
|
+
assert order_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
508
|
+
instrument.round_lot_size = 100
|
|
509
|
+
instrument.save()
|
|
510
|
+
|
|
511
|
+
# if instrument has a round lot size different than 1, we expect different behavior based on whether shares is positive or negative
|
|
512
|
+
assert order_proposal.get_round_lot_size(Decimal(66.0), instrument) == Decimal("100")
|
|
513
|
+
assert order_proposal.get_round_lot_size(Decimal(-66.0), instrument) == Decimal(-66.0)
|
|
514
|
+
assert order_proposal.get_round_lot_size(Decimal(-120), instrument) == Decimal(-200)
|
|
515
|
+
|
|
516
|
+
# exchange can disable rounding based on the lot size
|
|
517
|
+
instrument.exchange.apply_round_lot_size = False
|
|
518
|
+
instrument.exchange.save()
|
|
519
|
+
assert order_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
520
|
+
|
|
521
|
+
@patch.object(OrderProposal, "get_portfolio_total_asset_value")
|
|
522
|
+
def test_submit_round_lot_size(self, mock_fct, order_proposal, instrument_price_factory, order_factory):
|
|
523
|
+
initial_shares = Decimal("70")
|
|
524
|
+
price = instrument_price_factory.create(date=order_proposal.trade_date)
|
|
525
|
+
net_value = round(price.net_value, 4)
|
|
526
|
+
portfolio_value = initial_shares * net_value
|
|
527
|
+
mock_fct.return_value = portfolio_value
|
|
528
|
+
|
|
529
|
+
order_proposal.portfolio.only_weighting = False
|
|
530
|
+
order_proposal.portfolio.save()
|
|
531
|
+
instrument = price.instrument
|
|
532
|
+
instrument.round_lot_size = 100
|
|
533
|
+
instrument.save()
|
|
534
|
+
trade = order_factory.create(
|
|
535
|
+
shares=initial_shares,
|
|
536
|
+
order_proposal=order_proposal,
|
|
537
|
+
weighting=Decimal("1.0"),
|
|
538
|
+
underlying_instrument=price.instrument,
|
|
539
|
+
price=net_value,
|
|
540
|
+
)
|
|
541
|
+
warnings = order_proposal.submit()
|
|
542
|
+
order_proposal.save()
|
|
543
|
+
assert (
|
|
544
|
+
len(warnings) == 1
|
|
545
|
+
) # ensure that submit returns a warning concerning the rounded trade based on the lot size
|
|
546
|
+
trade.refresh_from_db()
|
|
547
|
+
assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
|
|
548
|
+
|
|
549
|
+
@patch.object(OrderProposal, "get_portfolio_total_asset_value")
|
|
550
|
+
def test_submit_round_fractional_shares(
|
|
551
|
+
self, mock_fct, instrument_price_factory, order_proposal, order_factory, asset_position_factory
|
|
552
|
+
):
|
|
553
|
+
initial_shares = Decimal("5.6")
|
|
554
|
+
price = instrument_price_factory.create(date=order_proposal.trade_date)
|
|
555
|
+
net_value = round(price.net_value, 4)
|
|
556
|
+
portfolio_value = initial_shares * net_value
|
|
557
|
+
mock_fct.return_value = portfolio_value
|
|
558
|
+
|
|
559
|
+
order_proposal.portfolio.only_weighting = False
|
|
560
|
+
order_proposal.portfolio.save()
|
|
561
|
+
|
|
562
|
+
trade = order_factory.create(
|
|
563
|
+
shares=Decimal("5.6"),
|
|
564
|
+
order_proposal=order_proposal,
|
|
565
|
+
weighting=Decimal("1.0"),
|
|
566
|
+
underlying_instrument=price.instrument,
|
|
567
|
+
price=net_value,
|
|
568
|
+
)
|
|
569
|
+
order_proposal.submit()
|
|
570
|
+
order_proposal.save()
|
|
571
|
+
trade.refresh_from_db()
|
|
572
|
+
assert trade.shares == 6 # we expect the fractional share to be rounded
|
|
573
|
+
assert trade.weighting == round((trade.shares * net_value) / portfolio_value, 8)
|
|
574
|
+
assert trade.weighting == round(
|
|
575
|
+
Decimal("1") + ((Decimal("6") - initial_shares) * net_value) / portfolio_value, 8
|
|
576
|
+
) # we expect the weighting to be updated accrodingly
|
|
577
|
+
|
|
578
|
+
def test_ex_post(
|
|
579
|
+
self, instrument_factory, asset_position_factory, instrument_price_factory, order_proposal_factory, portfolio
|
|
580
|
+
):
|
|
581
|
+
"""
|
|
582
|
+
Tests the ex-post rebalancing mechanism of a portfolio with two instruments.
|
|
583
|
+
Verifies that weights are correctly recalculated after submitting and approving a order proposal.
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
# --- Create instruments ---
|
|
587
|
+
msft = instrument_factory.create(currency=portfolio.currency)
|
|
588
|
+
apple = instrument_factory.create(currency=portfolio.currency)
|
|
589
|
+
|
|
590
|
+
# --- Key dates ---
|
|
591
|
+
d1 = date(2025, 6, 24)
|
|
592
|
+
d2 = date(2025, 6, 25)
|
|
593
|
+
d3 = date(2025, 6, 26)
|
|
594
|
+
d4 = date(2025, 6, 27)
|
|
595
|
+
|
|
596
|
+
# --- Create MSFT prices ---
|
|
597
|
+
msft_p1 = instrument_price_factory.create(instrument=msft, date=d1, net_value=Decimal("10"))
|
|
598
|
+
msft_p2 = instrument_price_factory.create(instrument=msft, date=d2, net_value=Decimal("8"))
|
|
599
|
+
msft_p3 = instrument_price_factory.create(instrument=msft, date=d3, net_value=Decimal("12"))
|
|
600
|
+
msft_p4 = instrument_price_factory.create(instrument=msft, date=d4, net_value=Decimal("15")) # noqa
|
|
601
|
+
|
|
602
|
+
# Calculate MSFT returns between dates
|
|
603
|
+
msft_r2 = msft_p2.net_value / msft_p1.net_value - Decimal("1") # noqa
|
|
604
|
+
msft_r3 = msft_p3.net_value / msft_p2.net_value - Decimal("1")
|
|
605
|
+
|
|
606
|
+
# --- Create Apple prices (stable) ---
|
|
607
|
+
apple_p1 = instrument_price_factory.create(instrument=apple, date=d1, net_value=Decimal("100"))
|
|
608
|
+
apple_p2 = instrument_price_factory.create(instrument=apple, date=d2, net_value=Decimal("100"))
|
|
609
|
+
apple_p3 = instrument_price_factory.create(instrument=apple, date=d3, net_value=Decimal("100"))
|
|
610
|
+
apple_p4 = instrument_price_factory.create(instrument=apple, date=d4, net_value=Decimal("100")) # noqa
|
|
611
|
+
|
|
612
|
+
# Apple returns (always 0 since price is stable)
|
|
613
|
+
apple_r2 = apple_p2.net_value / apple_p1.net_value - Decimal("1") # noqa
|
|
614
|
+
apple_r3 = apple_p3.net_value / apple_p2.net_value - Decimal("1")
|
|
615
|
+
|
|
616
|
+
# --- Create positions on d2 ---
|
|
617
|
+
msft_a2 = asset_position_factory.create(
|
|
618
|
+
portfolio=portfolio,
|
|
619
|
+
underlying_quote=msft,
|
|
620
|
+
date=d2,
|
|
621
|
+
initial_shares=10,
|
|
622
|
+
weighting=Decimal("0.44"),
|
|
623
|
+
)
|
|
624
|
+
apple_a2 = asset_position_factory.create(
|
|
625
|
+
portfolio=portfolio,
|
|
626
|
+
underlying_quote=apple,
|
|
627
|
+
date=d2,
|
|
628
|
+
initial_shares=1,
|
|
629
|
+
weighting=Decimal("0.56"),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Check that initial weights sum to 1
|
|
633
|
+
total_weight_d2 = msft_a2.weighting + apple_a2.weighting
|
|
634
|
+
assert total_weight_d2 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
635
|
+
|
|
636
|
+
# --- Calculate total portfolio return between d2 and d3 ---
|
|
637
|
+
portfolio_r3 = msft_a2.weighting * (Decimal("1.0") + msft_r3) + apple_a2.weighting * (
|
|
638
|
+
Decimal("1.0") + apple_r3
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# --- Create positions on d3 with weights adjusted for returns ---
|
|
642
|
+
|
|
643
|
+
# Check that weights on d2 sum to 1
|
|
644
|
+
total_weight_d2 = msft_a2.weighting + apple_a2.weighting
|
|
645
|
+
assert total_weight_d2 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
646
|
+
|
|
647
|
+
# --- Create a order proposal on d3 ---
|
|
648
|
+
order_proposal = order_proposal_factory.create(portfolio=portfolio, trade_date=d3)
|
|
649
|
+
order_proposal.reset_orders()
|
|
650
|
+
# Retrieve orders for each instrument
|
|
651
|
+
orders = order_proposal.get_orders()
|
|
652
|
+
trade_msft = orders.get(underlying_instrument=msft)
|
|
653
|
+
trade_apple = orders.get(underlying_instrument=apple)
|
|
654
|
+
# Check that trade weights are initially zero
|
|
655
|
+
assert trade_msft.weighting == Decimal("0")
|
|
656
|
+
assert trade_apple.weighting == Decimal("0")
|
|
657
|
+
|
|
658
|
+
msft_drifted = msft_a2.weighting * (Decimal("1.0") + msft_r3) / portfolio_r3
|
|
659
|
+
apple_drifted = apple_a2.weighting * (Decimal("1.0") + apple_r3) / portfolio_r3
|
|
660
|
+
# --- Adjust trade weights to target 50% each ---
|
|
661
|
+
target_weight = Decimal("0.5")
|
|
662
|
+
trade_msft.weighting = target_weight - msft_drifted
|
|
663
|
+
trade_msft.save()
|
|
664
|
+
|
|
665
|
+
trade_apple.weighting = target_weight - apple_drifted
|
|
666
|
+
trade_apple.save()
|
|
667
|
+
orders = order_proposal.get_orders()
|
|
668
|
+
trade_msft = orders.get(underlying_instrument=msft)
|
|
669
|
+
trade_apple = orders.get(underlying_instrument=apple)
|
|
670
|
+
|
|
671
|
+
# --- Check drift factors and effective weights ---
|
|
672
|
+
assert trade_msft.daily_return == pytest.approx(msft_r3, abs=Decimal("1e-6"))
|
|
673
|
+
assert trade_apple.daily_return == pytest.approx(apple_r3, abs=Decimal("1e-6"))
|
|
674
|
+
|
|
675
|
+
assert trade_msft._effective_weight == pytest.approx(msft_drifted, abs=Decimal("1e-6"))
|
|
676
|
+
assert trade_apple._effective_weight == pytest.approx(apple_drifted, abs=Decimal("1e-6"))
|
|
677
|
+
|
|
678
|
+
# Check that the target weight is the sum of drifted weight and adjustment
|
|
679
|
+
assert trade_msft._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
680
|
+
assert trade_apple._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
681
|
+
|
|
682
|
+
# --- Submit and approve the order proposal ---
|
|
683
|
+
order_proposal.submit()
|
|
684
|
+
order_proposal.save()
|
|
685
|
+
order_proposal.approve()
|
|
686
|
+
order_proposal.apply()
|
|
687
|
+
order_proposal.save()
|
|
688
|
+
|
|
689
|
+
# Final check that weights have been updated to 50%
|
|
690
|
+
assert order_proposal.portfolio.assets.get(underlying_instrument=msft).weighting == pytest.approx(
|
|
691
|
+
target_weight, abs=Decimal("1e-6")
|
|
692
|
+
)
|
|
693
|
+
assert order_proposal.portfolio.assets.get(underlying_instrument=apple).weighting == pytest.approx(
|
|
694
|
+
target_weight, abs=Decimal("1e-6")
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
def test_replay_reset_draft_order_proposal(
|
|
698
|
+
self, instrument_factory, instrument_price_factory, order_factory, order_proposal_factory
|
|
699
|
+
):
|
|
700
|
+
instrument = instrument_factory.create()
|
|
701
|
+
order_proposal = order_proposal_factory.create(trade_date=date.today() - BDay(2))
|
|
702
|
+
instrument_price_factory.create(instrument=instrument, date=date.today() - BDay(2))
|
|
703
|
+
instrument_price_factory.create(instrument=instrument, date=date.today() - BDay(1))
|
|
704
|
+
instrument_price_factory.create(instrument=instrument, date=date.today())
|
|
705
|
+
trade = order_factory.create(
|
|
706
|
+
underlying_instrument=instrument,
|
|
707
|
+
order_proposal=order_proposal,
|
|
708
|
+
weighting=1,
|
|
709
|
+
)
|
|
710
|
+
order_proposal.submit()
|
|
711
|
+
order_proposal.approve()
|
|
712
|
+
order_proposal.confirm()
|
|
713
|
+
order_proposal.save()
|
|
714
|
+
|
|
715
|
+
draft_tp = order_proposal_factory.create(portfolio=order_proposal.portfolio, trade_date=date.today() - BDay(1))
|
|
716
|
+
assert not Order.objects.filter(order_proposal=draft_tp).exists()
|
|
717
|
+
order_proposal.replay()
|
|
718
|
+
|
|
719
|
+
assert Order.objects.filter(order_proposal=draft_tp).count() == 1
|
|
720
|
+
assert Order.objects.get(
|
|
721
|
+
order_proposal=draft_tp, underlying_instrument=trade.underlying_instrument
|
|
722
|
+
).weighting == Decimal("0")
|
|
723
|
+
|
|
724
|
+
def test_order_submit_bellow_minimum_allowed_order_value(self, order_factory):
|
|
725
|
+
order = order_factory.create(price=Decimal(1), weighting=Decimal(1), shares=Decimal(999))
|
|
726
|
+
order.submit()
|
|
727
|
+
order.save()
|
|
728
|
+
assert order.shares == Decimal(999)
|
|
729
|
+
assert order.weighting == Decimal(1)
|
|
730
|
+
|
|
731
|
+
order.order_proposal.min_order_value = Decimal(1000)
|
|
732
|
+
order.order_proposal.save()
|
|
733
|
+
|
|
734
|
+
order.submit()
|
|
735
|
+
order.save()
|
|
736
|
+
assert order.shares == Decimal(0)
|
|
737
|
+
assert order.weighting == Decimal(0)
|
|
738
|
+
|
|
739
|
+
def test_order_submit_bellow_minimum_weighting(self, order_factory, order_proposal):
|
|
740
|
+
o1 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.8"))
|
|
741
|
+
o2 = order_factory.create(order_proposal=order_proposal, price=Decimal(1), weighting=Decimal("0.2"))
|
|
742
|
+
order_proposal.submit()
|
|
743
|
+
order_proposal.save()
|
|
744
|
+
|
|
745
|
+
o1.refresh_from_db()
|
|
746
|
+
o2.refresh_from_db()
|
|
747
|
+
assert o1.weighting == Decimal("0.8")
|
|
748
|
+
assert o2.weighting == Decimal("0.2")
|
|
749
|
+
|
|
750
|
+
order_proposal.min_weighting = Decimal("0.21")
|
|
751
|
+
order_proposal.backtodraft()
|
|
752
|
+
order_proposal.submit()
|
|
753
|
+
order_proposal.save()
|
|
754
|
+
|
|
755
|
+
o1.refresh_from_db()
|
|
756
|
+
o2.refresh_from_db()
|
|
757
|
+
assert o1.weighting == Decimal("0.8")
|
|
758
|
+
assert o2.weighting == Decimal("0")
|
|
759
|
+
|
|
760
|
+
order_proposal.approve()
|
|
761
|
+
order_proposal.apply()
|
|
762
|
+
order_proposal.save()
|
|
763
|
+
assert order_proposal.portfolio.assets.get(
|
|
764
|
+
date=order_proposal.trade_date, underlying_quote=o1.underlying_instrument
|
|
765
|
+
).weighting == Decimal("0.8")
|
|
766
|
+
assert order_proposal.portfolio.assets.get(
|
|
767
|
+
date=order_proposal.trade_date, underlying_quote=order_proposal.cash_component
|
|
768
|
+
).weighting == Decimal("0.2")
|
|
769
|
+
|
|
770
|
+
def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
|
|
771
|
+
order1 = order_factory.create(
|
|
772
|
+
order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")
|
|
773
|
+
)
|
|
774
|
+
order2 = order_factory.create(
|
|
775
|
+
order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.3")
|
|
776
|
+
)
|
|
777
|
+
order_proposal.submit()
|
|
778
|
+
order_proposal.approve()
|
|
779
|
+
order_proposal.apply()
|
|
780
|
+
order_proposal.save()
|
|
781
|
+
|
|
782
|
+
order1.refresh_from_db()
|
|
783
|
+
order2.refresh_from_db()
|
|
784
|
+
assert order1.desired_target_weight == Decimal("0.5")
|
|
785
|
+
assert order2.desired_target_weight == Decimal("0.5")
|
|
786
|
+
assert order1.weighting == Decimal("0.5")
|
|
787
|
+
assert order2.weighting == Decimal("0.5")
|
|
788
|
+
|
|
789
|
+
order1.desired_target_weight = Decimal("0.7")
|
|
790
|
+
order2.desired_target_weight = Decimal("0.3")
|
|
791
|
+
order1.save()
|
|
792
|
+
order2.save()
|
|
793
|
+
|
|
794
|
+
order_proposal.reset_orders(use_desired_target_weight=True)
|
|
795
|
+
order1.refresh_from_db()
|
|
796
|
+
order2.refresh_from_db()
|
|
797
|
+
assert order1.weighting == Decimal("0.7")
|
|
798
|
+
assert order2.weighting == Decimal("0.3")
|
|
799
|
+
|
|
800
|
+
def test_reset_order_proposal_keeps_target_cash_weight(self, order_factory, order_proposal_factory):
|
|
801
|
+
order_proposal = order_proposal_factory.create(
|
|
802
|
+
total_cash_weight=Decimal("0.02")
|
|
803
|
+
) # create a OP with total cash weight of 2%
|
|
804
|
+
|
|
805
|
+
# create orders that total weight account for only 50%
|
|
806
|
+
order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
|
|
807
|
+
order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.2"))
|
|
808
|
+
|
|
809
|
+
order_proposal.reset_orders()
|
|
810
|
+
assert order_proposal.get_orders().exclude(underlying_instrument__is_cash=True).aggregate(
|
|
811
|
+
s=Sum("target_weight")
|
|
812
|
+
)["s"] == Decimal("0.98"), "The total target weight leftover does not equal the stored total cash weight"
|
|
813
|
+
|
|
814
|
+
def test_convert_to_portfolio_always_100percent(self, order_proposal, order_factory):
|
|
815
|
+
o1 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.5"))
|
|
816
|
+
o2 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
|
|
817
|
+
|
|
818
|
+
portfolio = order_proposal._get_default_effective_portfolio(include_delta_weight=True)
|
|
819
|
+
assert portfolio.positions_map[o1.underlying_instrument.id].weighting == Decimal("0.5")
|
|
820
|
+
assert portfolio.positions_map[o2.underlying_instrument.id].weighting == Decimal("0.3")
|
|
821
|
+
assert portfolio.positions_map[order_proposal.cash_component.id].weighting == Decimal("0.2")
|
|
822
|
+
|
|
823
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
824
|
+
@patch.object(OrderProposal, "has_non_successful_checks", new_callable=PropertyMock)
|
|
825
|
+
def test_can_execute(
|
|
826
|
+
self, mock_has_non_successful_checks, mock_router, order_proposal, user_factory, mock_adapter
|
|
827
|
+
):
|
|
828
|
+
user = user_factory.create()
|
|
829
|
+
mock_router.return_value = mock_adapter
|
|
830
|
+
mock_has_non_successful_checks.return_value = False
|
|
831
|
+
order_proposal.status = OrderProposal.Status.APPROVED
|
|
832
|
+
order_proposal.execution_status = ""
|
|
833
|
+
|
|
834
|
+
assert order_proposal.can_execute(user) is True
|
|
835
|
+
order_proposal.approver = user.profile
|
|
836
|
+
assert order_proposal.can_execute(user) is False
|
|
837
|
+
user.is_superuser = True
|
|
838
|
+
assert order_proposal.can_execute(user) is True
|
|
839
|
+
|
|
840
|
+
mock_router.return_value = None
|
|
841
|
+
assert order_proposal.can_execute(user) is False
|
|
842
|
+
|
|
843
|
+
mock_router.return_value = mock_adapter
|
|
844
|
+
mock_has_non_successful_checks.return_value = True
|
|
845
|
+
assert order_proposal.can_execute(user) is False
|
|
846
|
+
|
|
847
|
+
mock_has_non_successful_checks.return_value = False
|
|
848
|
+
order_proposal.status = OrderProposal.Status.PENDING
|
|
849
|
+
assert order_proposal.can_execute(user) is False
|
|
850
|
+
|
|
851
|
+
order_proposal.status = OrderProposal.Status.APPROVED
|
|
852
|
+
order_proposal.execution_status = "something"
|
|
853
|
+
assert order_proposal.can_execute(user) is False
|
|
854
|
+
|
|
855
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
856
|
+
def test_refresh_execution_status(self, mock_custodian_router, order_proposal, mock_adapter):
|
|
857
|
+
mock_custodian_router.return_value = mock_adapter
|
|
858
|
+
mock_adapter.get_rebalance_status.return_value = (ExecutionStatus.PENDING, "detail")
|
|
859
|
+
|
|
860
|
+
with patch.object(order_proposal, "save", wraps=order_proposal.save) as mock_save:
|
|
861
|
+
order_proposal.refresh_execution_status()
|
|
862
|
+
assert order_proposal.execution_status == ExecutionStatus.PENDING
|
|
863
|
+
assert order_proposal.execution_status_detail == "detail"
|
|
864
|
+
mock_save.assert_called_once()
|
|
865
|
+
|
|
866
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
867
|
+
def test_cancel_rebalancing_success(self, mock_custodian_router, order_proposal, mock_adapter):
|
|
868
|
+
mock_custodian_router.return_value = mock_adapter
|
|
869
|
+
mock_adapter.cancel_rebalancing.return_value = True
|
|
870
|
+
with patch.object(order_proposal, "save", wraps=order_proposal.save) as mock_save:
|
|
871
|
+
result = order_proposal.cancel_rebalancing()
|
|
872
|
+
assert result is True
|
|
873
|
+
assert order_proposal.execution_status == ExecutionStatus.CANCELLED
|
|
874
|
+
assert order_proposal.execution_comment == ""
|
|
875
|
+
assert order_proposal.execution_status_detail == ""
|
|
876
|
+
mock_save.assert_called_once()
|
|
877
|
+
|
|
878
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
879
|
+
def test_cancel_rebalancing_failure(self, mock_custodian_router, order_proposal, mock_adapter):
|
|
880
|
+
mock_custodian_router.return_value = mock_adapter
|
|
881
|
+
mock_adapter.cancel_rebalancing.return_value = False
|
|
882
|
+
with patch.object(order_proposal, "save", wraps=order_proposal.save) as mock_save:
|
|
883
|
+
result = order_proposal.cancel_rebalancing()
|
|
884
|
+
assert result is False
|
|
885
|
+
# No change in status or calls to save
|
|
886
|
+
mock_save.assert_not_called()
|
|
887
|
+
|
|
888
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
889
|
+
@patch.object(OrderProposal, "prepare_orders_for_execution")
|
|
890
|
+
@patch.object(OrderProposal, "handle_orders")
|
|
891
|
+
def test_execute_orders_success(self, mock_handler_error, mock_fct, mock_router, order_proposal, mock_adapter):
|
|
892
|
+
mock_router.return_value = mock_adapter
|
|
893
|
+
# Arrange
|
|
894
|
+
orders = ["order1", "order2"]
|
|
895
|
+
mock_fct.return_value = orders
|
|
896
|
+
confirmed_orders = ["confirmed1", "confirmed2"]
|
|
897
|
+
status = ExecutionStatus.PENDING
|
|
898
|
+
comment = "Success"
|
|
899
|
+
mock_adapter.submit_rebalancing.return_value = (confirmed_orders, (status, comment))
|
|
900
|
+
|
|
901
|
+
# Act
|
|
902
|
+
with patch.object(order_proposal, "save") as mock_save:
|
|
903
|
+
order_proposal.execute_orders(prioritize_target_weight=True)
|
|
904
|
+
|
|
905
|
+
# Assert
|
|
906
|
+
mock_fct.assert_called_once_with(prioritize_target_weight=True)
|
|
907
|
+
mock_adapter.submit_rebalancing.assert_called_once_with(orders)
|
|
908
|
+
mock_handler_error.assert_called_once_with(confirmed_orders)
|
|
909
|
+
assert order_proposal.execution_status == status
|
|
910
|
+
assert order_proposal.execution_comment == comment
|
|
911
|
+
mock_save.assert_called_once()
|
|
912
|
+
|
|
913
|
+
@patch.object(OrderProposal, "custodian_router", new_callable=PropertyMock)
|
|
914
|
+
@patch.object(OrderProposal, "prepare_orders_for_execution")
|
|
915
|
+
@patch.object(OrderProposal, "handle_orders")
|
|
916
|
+
def test_execute_orders_on_failure(self, mock_handler_error, mock_fct, mock_router, order_proposal, mock_adapter):
|
|
917
|
+
mock_router.return_value = mock_adapter
|
|
918
|
+
# Arrange
|
|
919
|
+
orders = ["order1", "order2"]
|
|
920
|
+
mock_fct.return_value = orders
|
|
921
|
+
mock_adapter.submit_rebalancing.side_effect = RoutingException("Failure!")
|
|
922
|
+
|
|
923
|
+
# Act
|
|
924
|
+
with patch.object(order_proposal, "save") as mock_save:
|
|
925
|
+
order_proposal.execute_orders(prioritize_target_weight=True)
|
|
926
|
+
|
|
927
|
+
# Assert
|
|
928
|
+
mock_fct.assert_called_once_with(prioritize_target_weight=True)
|
|
929
|
+
mock_adapter.submit_rebalancing.assert_called_once_with(orders)
|
|
930
|
+
mock_handler_error.assert_not_called()
|
|
931
|
+
assert order_proposal.execution_status == ExecutionStatus.FAILED
|
|
932
|
+
assert order_proposal.execution_comment == "Failure!"
|
|
933
|
+
mock_save.assert_called_once()
|
|
934
|
+
|
|
935
|
+
def test_prepare_orders_for_execution(self, order_proposal, order_factory, instrument_factory, equity_factory):
|
|
936
|
+
invalid_equity = equity_factory.create(refinitiv_identifier_code=None, ticker=None, sedol=None)
|
|
937
|
+
exotic_instrument = instrument_factory.create()
|
|
938
|
+
cash_instrument = instrument_factory.create(is_cash=True)
|
|
939
|
+
order_valid = order_factory.create(
|
|
940
|
+
order_proposal=order_proposal,
|
|
941
|
+
weighting=Decimal(0.6),
|
|
942
|
+
shares=Decimal(800),
|
|
943
|
+
execution_instruction=ExecutionInstruction.LIMIT_ORDER,
|
|
944
|
+
underlying_instrument=equity_factory.create(),
|
|
945
|
+
)
|
|
946
|
+
order_valid_but_unsupported_asset_class = order_factory.create(
|
|
947
|
+
order_proposal=order_proposal,
|
|
948
|
+
weighting=Decimal(0.6),
|
|
949
|
+
shares=Decimal(800),
|
|
950
|
+
execution_instruction=ExecutionInstruction.LIMIT_ORDER,
|
|
951
|
+
underlying_instrument=exotic_instrument,
|
|
952
|
+
)
|
|
953
|
+
order_invalid_instrument = order_factory.create(
|
|
954
|
+
order_proposal=order_proposal,
|
|
955
|
+
weighting=Decimal(0.3),
|
|
956
|
+
shares=Decimal(800),
|
|
957
|
+
underlying_instrument=invalid_equity,
|
|
958
|
+
)
|
|
959
|
+
order_zero_delta = order_factory.create(
|
|
960
|
+
order_proposal=order_proposal,
|
|
961
|
+
weighting=Decimal(0),
|
|
962
|
+
shares=Decimal(0),
|
|
963
|
+
underlying_instrument=equity_factory.create(),
|
|
964
|
+
)
|
|
965
|
+
order_cash = order_factory.create(
|
|
966
|
+
order_proposal=order_proposal,
|
|
967
|
+
weighting=Decimal(0.1),
|
|
968
|
+
shares=Decimal(200),
|
|
969
|
+
underlying_instrument=cash_instrument,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
orders_dto = order_proposal.prepare_orders_for_execution()
|
|
973
|
+
assert len(orders_dto) == 1
|
|
974
|
+
order = orders_dto[0]
|
|
975
|
+
assert order.refinitiv_identifier_code == order_valid.underlying_instrument.refinitiv_identifier_code
|
|
976
|
+
assert (
|
|
977
|
+
order.bloomberg_ticker
|
|
978
|
+
== order_valid.underlying_instrument.ticker
|
|
979
|
+
+ " "
|
|
980
|
+
+ order_valid.underlying_instrument.exchange.bbg_composite
|
|
981
|
+
)
|
|
982
|
+
assert order.sedol == order_valid.underlying_instrument.sedol
|
|
983
|
+
assert order.execution_instruction == ExecutionInstruction.LIMIT_ORDER
|
|
984
|
+
assert order.target_shares == order_valid.shares
|
|
985
|
+
assert order.shares == order_valid.shares
|
|
986
|
+
assert order.weighting == order_valid.weighting
|
|
987
|
+
assert order.target_weight == order_valid.weighting
|
|
988
|
+
assert order.trade_date == order_proposal.trade_date
|
|
989
|
+
|
|
990
|
+
order_invalid_instrument.refresh_from_db()
|
|
991
|
+
order_valid_but_unsupported_asset_class.refresh_from_db()
|
|
992
|
+
order_zero_delta.refresh_from_db()
|
|
993
|
+
order_cash.refresh_from_db()
|
|
994
|
+
|
|
995
|
+
assert order_zero_delta.execution_status == Order.ExecutionStatus.IGNORED
|
|
996
|
+
assert order_cash.execution_status == Order.ExecutionStatus.IGNORED
|
|
997
|
+
assert order_invalid_instrument.execution_status == Order.ExecutionStatus.FAILED
|
|
998
|
+
assert order_invalid_instrument.execution_comment == "Underlying instrument does not have a valid identifier."
|
|
999
|
+
assert order_valid_but_unsupported_asset_class.execution_status == Order.ExecutionStatus.FAILED
|
|
1000
|
+
assert order_valid_but_unsupported_asset_class.execution_comment.startswith("Unsupported asset class")
|
|
1001
|
+
|
|
1002
|
+
@patch.object(OrderProposal, "get_portfolio_total_asset_value")
|
|
1003
|
+
def test_handle_orders(self, mock_fct, order_proposal, order_factory):
|
|
1004
|
+
o1 = order_factory.create(
|
|
1005
|
+
order_proposal=order_proposal, weighting=Decimal("0.8"), shares=Decimal(800), price=Decimal(2)
|
|
1006
|
+
)
|
|
1007
|
+
o2 = order_factory.create(
|
|
1008
|
+
order_proposal=order_proposal, weighting=Decimal("0.2"), shares=Decimal(200), price=Decimal(2)
|
|
1009
|
+
)
|
|
1010
|
+
portfolio_value = Decimal(800) * Decimal(2) + Decimal(200) * Decimal(2)
|
|
1011
|
+
mock_fct.return_value = portfolio_value
|
|
1012
|
+
|
|
1013
|
+
expected_shares = round(800 * 2 / 1.2)
|
|
1014
|
+
order_proposal.handle_orders(
|
|
1015
|
+
[
|
|
1016
|
+
OrderDTO(
|
|
1017
|
+
id=o1.id,
|
|
1018
|
+
asset_class=OrderDTO.AssetType.EQUITY,
|
|
1019
|
+
target_weight=0.8,
|
|
1020
|
+
weighting=0.8,
|
|
1021
|
+
trade_date=o1.value_date,
|
|
1022
|
+
execution_price=1.2,
|
|
1023
|
+
shares=expected_shares, # we simulate a market fluctuation
|
|
1024
|
+
target_shares=expected_shares,
|
|
1025
|
+
comment="some comment",
|
|
1026
|
+
)
|
|
1027
|
+
]
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
o1.refresh_from_db()
|
|
1031
|
+
with pytest.raises(Order.DoesNotExist):
|
|
1032
|
+
o2.refresh_from_db()
|
|
1033
|
+
|
|
1034
|
+
assert o1.execution_status == Order.ExecutionStatus.CONFIRMED
|
|
1035
|
+
assert o1.execution_comment == "some comment"
|
|
1036
|
+
|
|
1037
|
+
# We do not update these fields anymore, we keep the test around in case it comes back
|
|
1038
|
+
# assert o1.price == Decimal("1.2") # check the the new execution price was updated
|
|
1039
|
+
# assert (
|
|
1040
|
+
# o1.shares == expected_shares
|
|
1041
|
+
# ) # check that the new shares based on the execution price got updated as well
|
|
1042
|
+
# assert (
|
|
1043
|
+
# o1.weighting == round(Decimal(expected_shares * 1.2), 2) / portfolio_value
|
|
1044
|
+
# ) # weighting should change slightly as we round the number of shares
|
|
1045
|
+
|
|
1046
|
+
assert order_proposal.orders.get(underlying_instrument__is_cash=True).weighting == Decimal("1") - o1.weighting
|