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,195 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from requests import HTTPError
|
|
6
|
+
|
|
7
|
+
from wbportfolio.api_clients.ubs import UBSNeoAPIClient
|
|
8
|
+
from wbportfolio.pms.typing import Order
|
|
9
|
+
|
|
10
|
+
from .. import ExecutionInstruction, ExecutionStatus, RoutingException
|
|
11
|
+
from . import BaseCustodianAdapter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("oms")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ASSET_CLASS_MAP = {
|
|
17
|
+
Order.AssetType.EQUITY: "EQUITY",
|
|
18
|
+
Order.AssetType.AMERICAN_DEPOSITORY_RECEIPT: "EQUITY",
|
|
19
|
+
} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
|
|
20
|
+
ASSET_CLASS_MAP_INV = {
|
|
21
|
+
v: k for k, v in ASSET_CLASS_MAP.items()
|
|
22
|
+
} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
|
|
23
|
+
|
|
24
|
+
STATUS_MAP = {
|
|
25
|
+
"Amend Pending": ExecutionStatus.PENDING,
|
|
26
|
+
"Cancel Pending": ExecutionStatus.PENDING,
|
|
27
|
+
"Cancelled": ExecutionStatus.CANCELLED,
|
|
28
|
+
"Complete": ExecutionStatus.COMPLETED,
|
|
29
|
+
"Complete (Order Cancelled)": ExecutionStatus.COMPLETED,
|
|
30
|
+
"Complete (Partial Fill)": ExecutionStatus.COMPLETED,
|
|
31
|
+
"In Draft": ExecutionStatus.IN_DRAFT,
|
|
32
|
+
"Pending Approval": ExecutionStatus.PENDING,
|
|
33
|
+
"Pending Execution": ExecutionStatus.PENDING,
|
|
34
|
+
"Rebalance Cancelled": ExecutionStatus.CANCELLED,
|
|
35
|
+
"Rebalance Cancelled (Executing partially)": ExecutionStatus.CANCELLED,
|
|
36
|
+
"Rejected": ExecutionStatus.REJECTED,
|
|
37
|
+
"Rejection Acknowledged": ExecutionStatus.PENDING,
|
|
38
|
+
"Waiting for Response": ExecutionStatus.PENDING,
|
|
39
|
+
}
|
|
40
|
+
EXECUTION_INSTRUCTION_MAP = {
|
|
41
|
+
ExecutionInstruction.MARKET_ON_CLOSE: "MARKET_ON_CLOSE",
|
|
42
|
+
ExecutionInstruction.GUARANTEED_MARKET_ON_CLOSE: "GUARANTEED_MARKET_ON_CLOSE",
|
|
43
|
+
ExecutionInstruction.GUARANTEED_MARKET_ON_OPEN: "GUARANTEED_MARKET_ON_OPEN",
|
|
44
|
+
ExecutionInstruction.GPW_MARKET_ON_CLOSE: "GPW_MARKET_ON_CLOSE",
|
|
45
|
+
ExecutionInstruction.MARKET_ON_OPEN: "MARKET_ON_OPEN",
|
|
46
|
+
ExecutionInstruction.IN_LINE_WITH_VOLUME: "IN_LINE_WITH_VOLUME",
|
|
47
|
+
ExecutionInstruction.LIMIT_ORDER: "LIMIT_ORDER",
|
|
48
|
+
ExecutionInstruction.VWAP: "VWAP",
|
|
49
|
+
ExecutionInstruction.TWAP: "TWAP",
|
|
50
|
+
}
|
|
51
|
+
EXECUTION_INSTRUCTION_MAP_INV = {v: k for k, v in EXECUTION_INSTRUCTION_MAP.items()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CustodianAdapter(BaseCustodianAdapter):
|
|
55
|
+
client: UBSNeoAPIClient
|
|
56
|
+
|
|
57
|
+
def __init__(self, *args, raise_exception: bool = False, **kwargs):
|
|
58
|
+
super().__init__(*args, **kwargs)
|
|
59
|
+
self.raise_exception = raise_exception
|
|
60
|
+
|
|
61
|
+
def _handle_response(self, res):
|
|
62
|
+
logger.info(res["message"])
|
|
63
|
+
if errors := res.get("errors"):
|
|
64
|
+
logger.warning(errors)
|
|
65
|
+
if self.raise_exception:
|
|
66
|
+
raise RoutingException(errors)
|
|
67
|
+
|
|
68
|
+
def _serialize_execution_instruction(
|
|
69
|
+
self, execution_instruction: ExecutionInstruction, execution_parameters: dict
|
|
70
|
+
):
|
|
71
|
+
repr = EXECUTION_INSTRUCTION_MAP[execution_instruction]
|
|
72
|
+
if execution_parameters:
|
|
73
|
+
if execution_instruction == ExecutionInstruction.IN_LINE_WITH_VOLUME:
|
|
74
|
+
repr += f':{execution_parameters["percent"]:.0f%}'
|
|
75
|
+
elif execution_instruction == ExecutionInstruction.LIMIT_ORDER:
|
|
76
|
+
repr += f':{execution_parameters["price"]:.1f}'
|
|
77
|
+
if good_for_date := execution_parameters.get("good_for_date"):
|
|
78
|
+
repr += f",{good_for_date}"
|
|
79
|
+
elif (
|
|
80
|
+
execution_instruction == ExecutionInstruction.VWAP
|
|
81
|
+
or execution_instruction == ExecutionInstruction.TWAP
|
|
82
|
+
):
|
|
83
|
+
repr += f':{execution_parameters["period"]},{execution_parameters["time"]}'
|
|
84
|
+
return repr
|
|
85
|
+
|
|
86
|
+
def serialize_orders(self, orders: list[Order]) -> list[dict[str, str]]:
|
|
87
|
+
items = []
|
|
88
|
+
for order in orders:
|
|
89
|
+
if order.refinitiv_identifier_code:
|
|
90
|
+
identifier_type, identifier = "RIC", order.refinitiv_identifier_code
|
|
91
|
+
elif order.bloomberg_ticker:
|
|
92
|
+
identifier_type, identifier = "BBTICKER", order.bloomberg_ticker
|
|
93
|
+
else:
|
|
94
|
+
identifier_type, identifier = "SEDOL", order.sedol
|
|
95
|
+
item = {
|
|
96
|
+
"assetClass": ASSET_CLASS_MAP[order.asset_class],
|
|
97
|
+
"identifierType": identifier_type,
|
|
98
|
+
"identifier": identifier,
|
|
99
|
+
"executionInstruction": self._serialize_execution_instruction(
|
|
100
|
+
order.execution_instruction, order.execution_instruction_parameters
|
|
101
|
+
),
|
|
102
|
+
"userElementId": str(order.id),
|
|
103
|
+
"tradeDate": order.trade_date.strftime("%Y-%m-%d"),
|
|
104
|
+
}
|
|
105
|
+
if order.shares:
|
|
106
|
+
item["sharesToTrade"] = str(order.shares)
|
|
107
|
+
else:
|
|
108
|
+
item["targetWeight"] = str(order.target_weight * 100)
|
|
109
|
+
items.append(item)
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
def deserialize_items(self, items: list[dict[str, str]]):
|
|
113
|
+
orders = []
|
|
114
|
+
for item in items:
|
|
115
|
+
orders.append(
|
|
116
|
+
Order(
|
|
117
|
+
id=item.get("userElementId"),
|
|
118
|
+
asset_class=ASSET_CLASS_MAP_INV[item.get("assetClass")],
|
|
119
|
+
refinitiv_identifier_code=item.get(
|
|
120
|
+
"ric", item["identifier"] if item.get("identifierType") == "RIC" else None
|
|
121
|
+
),
|
|
122
|
+
bloomberg_ticker=item["identifier"] if item.get("identifierType") == "BBTICKER" else None,
|
|
123
|
+
sedol=item["identifier"] if item.get("identifierType") == "SEDOL" else None,
|
|
124
|
+
trade_date=datetime.strptime(item.get("tradeDate"), "%Y-%m-%d"),
|
|
125
|
+
target_weight=float(item["targetWeight"]) / 100 if "targetWeight" in item else None,
|
|
126
|
+
shares=float(item["sharesToTrade"]) if "sharesToTrade" in item else None,
|
|
127
|
+
execution_instruction=EXECUTION_INSTRUCTION_MAP_INV[item["executionInstruction"]],
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return orders
|
|
131
|
+
|
|
132
|
+
def authenticate(self) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Authenticate or renew tokens with the custodian API.
|
|
135
|
+
Raises an exception if authentication fails.
|
|
136
|
+
"""
|
|
137
|
+
self.client = UBSNeoAPIClient(settings.UBS_NEO_API_TOKEN)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def is_valid(self) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Check whether the given isin is valid and can be rebalanced
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
status_res = self.client.get_rebalance_service_status()
|
|
147
|
+
|
|
148
|
+
isin_res = self.client.get_rebalance_status_for_isin(self.isin)
|
|
149
|
+
self._handle_response(status_res)
|
|
150
|
+
self._handle_response(isin_res)
|
|
151
|
+
return (
|
|
152
|
+
status_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
153
|
+
and isin_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
154
|
+
)
|
|
155
|
+
except (HTTPError, KeyError) as e:
|
|
156
|
+
logger.warning(f"Couldn't validate adapter: {str(e)}")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
def submit_rebalancing(
|
|
160
|
+
self, items: list[dict[str, str]], as_draft: bool = True
|
|
161
|
+
) -> tuple[list[dict[str, str]], str]:
|
|
162
|
+
"""
|
|
163
|
+
Submit a rebalance order for the certificate.
|
|
164
|
+
"""
|
|
165
|
+
if not as_draft:
|
|
166
|
+
res = self.client.submit_rebalance(self.isin, items)
|
|
167
|
+
else:
|
|
168
|
+
res = self.client.save_draft(self.isin, items)
|
|
169
|
+
self._handle_response(res)
|
|
170
|
+
return res["rebalanceItems"], res["message"]
|
|
171
|
+
|
|
172
|
+
def cancel_current_rebalancing(self) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Cancel an existing rebalance order identified by ISIN.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
res = self.client.cancel_rebalance(self.isin)
|
|
178
|
+
self._handle_response(res)
|
|
179
|
+
return res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
180
|
+
except (HTTPError, KeyError):
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
|
|
184
|
+
res = self.client.get_rebalance_status_for_isin(self.isin)
|
|
185
|
+
self._handle_response(res)
|
|
186
|
+
status = res.get("rebalanceStatus", "")
|
|
187
|
+
return STATUS_MAP.get(status, ExecutionStatus.UNKNOWN), status
|
|
188
|
+
|
|
189
|
+
def get_current_rebalancing(self) -> list[dict[str, str]]:
|
|
190
|
+
"""
|
|
191
|
+
Fetch the current rebalance request details for a certificate.
|
|
192
|
+
"""
|
|
193
|
+
res = self.client.get_current_rebalance_request(self.isin)
|
|
194
|
+
self._handle_response(res)
|
|
195
|
+
return res["rebalanceItems"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
from wbportfolio.order_routing import ExecutionStatus
|
|
4
|
+
from wbportfolio.order_routing.adapters import BaseCustodianAdapter
|
|
5
|
+
from wbportfolio.pms.typing import Order
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Router:
|
|
9
|
+
def __init__(self, adapter: BaseCustodianAdapter):
|
|
10
|
+
self.adapter = adapter
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def submit_as_draft(self):
|
|
14
|
+
return getattr(settings, "DEBUG", True) or getattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
|
|
15
|
+
|
|
16
|
+
def submit_rebalancing(self, orders: list[Order]) -> tuple[list[Order], tuple[str, str]]:
|
|
17
|
+
"""
|
|
18
|
+
Submit a rebalance order for the certificate.
|
|
19
|
+
"""
|
|
20
|
+
items = self.adapter.serialize_orders(orders)
|
|
21
|
+
confirmed_items, msg = self.adapter.submit_rebalancing(items, as_draft=self.submit_as_draft)
|
|
22
|
+
status = ExecutionStatus.IN_DRAFT if self.submit_as_draft else ExecutionStatus.PENDING
|
|
23
|
+
return self.adapter.deserialize_items(confirmed_items), (status, msg)
|
|
24
|
+
|
|
25
|
+
def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
|
|
26
|
+
return self.adapter.get_rebalance_status()
|
|
27
|
+
|
|
28
|
+
def cancel_rebalancing(self) -> bool:
|
|
29
|
+
return self.adapter.cancel_current_rebalancing()
|
|
30
|
+
|
|
31
|
+
def get_current_rebalancing_request(self) -> list[Order]:
|
|
32
|
+
items = self.adapter.get_current_rebalancing()
|
|
33
|
+
return self.adapter.deserialize_items(items)
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from unittest.mock import MagicMock, PropertyMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
from wbportfolio.order_routing import ExecutionStatus
|
|
7
|
+
from wbportfolio.order_routing.router import Router
|
|
8
|
+
from wbportfolio.pms.typing import Order
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def mock_adapter():
|
|
13
|
+
adapter = MagicMock()
|
|
14
|
+
return adapter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def router(mock_adapter):
|
|
19
|
+
return Router(adapter=mock_adapter)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_submit_as_draft_from_settings(monkeypatch, router):
|
|
23
|
+
# Test default True if settings attribute missing
|
|
24
|
+
monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
|
|
25
|
+
monkeypatch.setattr(settings, "DEBUG", False)
|
|
26
|
+
assert router.submit_as_draft is True
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
|
|
29
|
+
monkeypatch.setattr(settings, "DEBUG", True)
|
|
30
|
+
assert router.submit_as_draft is True
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
|
|
33
|
+
monkeypatch.setattr(settings, "DEBUG", True)
|
|
34
|
+
assert router.submit_as_draft is True
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
|
|
37
|
+
monkeypatch.setattr(settings, "DEBUG", False)
|
|
38
|
+
assert router.submit_as_draft is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
|
|
42
|
+
def test_submit_rebalancing_calls_adapter_as_draft(mock_property, router, mock_adapter):
|
|
43
|
+
mock_property.return_value = True
|
|
44
|
+
orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
|
|
45
|
+
serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
|
|
46
|
+
confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
|
|
47
|
+
msg = "Success message"
|
|
48
|
+
|
|
49
|
+
mock_adapter.serialize_orders.return_value = serialized_orders
|
|
50
|
+
mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
|
|
51
|
+
mock_adapter.deserialize_items.return_value = orders
|
|
52
|
+
|
|
53
|
+
result_orders, (status, message) = router.submit_rebalancing(orders)
|
|
54
|
+
assert result_orders == orders
|
|
55
|
+
assert status == ExecutionStatus.IN_DRAFT
|
|
56
|
+
assert message == msg
|
|
57
|
+
mock_adapter.serialize_orders.assert_called_once_with(orders)
|
|
58
|
+
mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=True)
|
|
59
|
+
mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
|
|
63
|
+
def test_submit_rebalancing_calls_adapter(mock_property, router, mock_adapter):
|
|
64
|
+
mock_property.return_value = False
|
|
65
|
+
orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
|
|
66
|
+
serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
|
|
67
|
+
confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
|
|
68
|
+
msg = "Success message"
|
|
69
|
+
|
|
70
|
+
mock_adapter.serialize_orders.return_value = serialized_orders
|
|
71
|
+
mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
|
|
72
|
+
mock_adapter.deserialize_items.return_value = orders
|
|
73
|
+
|
|
74
|
+
result_orders, (status, message) = router.submit_rebalancing(orders)
|
|
75
|
+
assert result_orders == orders
|
|
76
|
+
assert status == ExecutionStatus.PENDING
|
|
77
|
+
assert message == msg
|
|
78
|
+
mock_adapter.serialize_orders.assert_called_once_with(orders)
|
|
79
|
+
mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=False)
|
|
80
|
+
mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_get_rebalance_status_returns_adapter_status(router, mock_adapter):
|
|
84
|
+
expected_status = ExecutionStatus.PENDING
|
|
85
|
+
expected_msg = "Status message"
|
|
86
|
+
mock_adapter.get_rebalance_status.return_value = (expected_status, expected_msg)
|
|
87
|
+
|
|
88
|
+
status, msg = router.get_rebalance_status()
|
|
89
|
+
assert status == expected_status
|
|
90
|
+
assert msg == expected_msg
|
|
91
|
+
mock_adapter.get_rebalance_status.assert_called_once()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_cancel_rebalancing_returns_adapter_result(router, mock_adapter):
|
|
95
|
+
mock_adapter.cancel_current_rebalancing.return_value = True
|
|
96
|
+
result = router.cancel_rebalancing()
|
|
97
|
+
assert result is True
|
|
98
|
+
mock_adapter.cancel_current_rebalancing.assert_called_once()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_get_current_rebalancing_request_returns_deserialized(router, mock_adapter):
|
|
102
|
+
serialized_orders = ["order1", "order2"]
|
|
103
|
+
deserialized_orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
|
|
104
|
+
mock_adapter.get_current_rebalancing.return_value = serialized_orders
|
|
105
|
+
mock_adapter.deserialize_items.return_value = deserialized_orders
|
|
106
|
+
|
|
107
|
+
result = router.get_current_rebalancing_request()
|
|
108
|
+
assert result == deserialized_orders
|
|
109
|
+
mock_adapter.get_current_rebalancing.assert_called_once()
|
|
110
|
+
mock_adapter.deserialize_items.assert_called_once_with(serialized_orders)
|
wbportfolio/permissions.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from rest_framework.permissions import IsAuthenticated
|
|
2
|
+
|
|
1
3
|
from wbportfolio.models import PortfolioRole
|
|
2
4
|
|
|
3
5
|
|
|
@@ -11,3 +13,8 @@ def is_portfolio_manager(request):
|
|
|
11
13
|
|
|
12
14
|
def is_analyst(request):
|
|
13
15
|
return PortfolioRole.is_analyst(request.user.profile)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IsPortfolioManager(IsAuthenticated):
|
|
19
|
+
def has_permission(self, request, view):
|
|
20
|
+
return is_portfolio_manager(request)
|
|
@@ -2,6 +2,8 @@ import numpy as np
|
|
|
2
2
|
import pandas as pd
|
|
3
3
|
from skfolio import Portfolio as BasePortfolio
|
|
4
4
|
|
|
5
|
+
from .utils import fix_quantization_error
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
class Portfolio(BasePortfolio):
|
|
7
9
|
@property
|
|
@@ -16,21 +18,27 @@ class Portfolio(BasePortfolio):
|
|
|
16
18
|
)
|
|
17
19
|
return df
|
|
18
20
|
|
|
19
|
-
def
|
|
21
|
+
def get_contributions(self) -> tuple[pd.Series, float]:
|
|
22
|
+
returns = self.X.iloc[-1, :].T
|
|
23
|
+
weights = self.all_weights_per_observation.iloc[-1, :].T
|
|
24
|
+
portfolio_returns = (weights * (returns + 1.0)).sum()
|
|
25
|
+
return returns, portfolio_returns
|
|
26
|
+
|
|
27
|
+
def get_next_weights(self, round_precision: int = 8) -> dict[int, float]:
|
|
20
28
|
"""
|
|
21
29
|
Given the next returns, compute the drifted weights of this portfolio
|
|
22
|
-
|
|
30
|
+
round_precision: Round the weight to the given round number and ensure the total weight reflects this. Default to 8 decimals
|
|
23
31
|
Returns:
|
|
24
32
|
A dictionary of weights (instrument ids as keys and weights as values)
|
|
25
33
|
"""
|
|
26
|
-
returns = self.
|
|
34
|
+
returns, portfolio_returns = self.get_contributions()
|
|
27
35
|
weights = self.all_weights_per_observation.iloc[-1, :].T
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
return
|
|
36
|
+
next_weights = weights * (returns + 1.0) / portfolio_returns
|
|
37
|
+
next_weights = next_weights.dropna()
|
|
38
|
+
next_weights = next_weights / next_weights.sum()
|
|
39
|
+
if round_precision and not next_weights.empty:
|
|
40
|
+
next_weights = fix_quantization_error(next_weights, round_precision)
|
|
41
|
+
return {i: round(w, round_precision) for i, w in next_weights.items()} # handle float precision manually
|
|
34
42
|
|
|
35
43
|
def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
|
|
36
44
|
expected_returns = self.weights @ self.X.iloc[-1, :].T
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fix_quantization_error(df: pd.Series, round_precision: int):
|
|
5
|
+
df = df.round(round_precision)
|
|
6
|
+
quantization_error = 1.0 - df.sum()
|
|
7
|
+
largest_weight = df.idxmax()
|
|
8
|
+
df.loc[largest_weight] = df.loc[largest_weight] + quantization_error
|
|
9
|
+
return df
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .handler import TradingService
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import cvxpy as cp
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from wbportfolio.pms.typing import TradeBatch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TradeShareOptimizer:
|
|
10
|
+
def __init__(self, batch: TradeBatch, portfolio_total_value: float):
|
|
11
|
+
self.batch = batch
|
|
12
|
+
self.portfolio_total_value = portfolio_total_value
|
|
13
|
+
|
|
14
|
+
def optimize(self, target_cash: float = 0.99):
|
|
15
|
+
try:
|
|
16
|
+
return self.optimize_trade_share(target_cash)
|
|
17
|
+
except ValueError:
|
|
18
|
+
return self.floor_trade_share()
|
|
19
|
+
|
|
20
|
+
def optimize_trade_share(self, target_cash: float = 0.01):
|
|
21
|
+
prices_fx_portfolio = np.array([trade.price_fx_portfolio for trade in self.batch.trades])
|
|
22
|
+
target_allocs = np.array([trade.target_weight for trade in self.batch.trades])
|
|
23
|
+
|
|
24
|
+
# Decision variable: number of shares (integers)
|
|
25
|
+
shares = cp.Variable(len(prices_fx_portfolio), integer=True)
|
|
26
|
+
|
|
27
|
+
# Calculate portfolio values
|
|
28
|
+
portfolio_values = cp.multiply(shares, prices_fx_portfolio)
|
|
29
|
+
|
|
30
|
+
# Target values based on allocations
|
|
31
|
+
target_values = self.portfolio_total_value * target_allocs
|
|
32
|
+
|
|
33
|
+
# Objective: minimize absolute deviation from target values
|
|
34
|
+
objective = cp.Minimize(cp.sum(cp.abs(portfolio_values - target_values)))
|
|
35
|
+
|
|
36
|
+
# Constraints
|
|
37
|
+
constraints = [
|
|
38
|
+
shares >= 0, # No short selling
|
|
39
|
+
cp.sum(portfolio_values) <= self.portfolio_total_value, # Don't exceed budget
|
|
40
|
+
cp.sum(portfolio_values) >= (1.0 - target_cash) * self.portfolio_total_value, # Use at least 99% of budget
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Solve
|
|
44
|
+
problem = cp.Problem(objective, constraints)
|
|
45
|
+
problem.solve(solver=cp.CBC)
|
|
46
|
+
|
|
47
|
+
if problem != "optimal":
|
|
48
|
+
raise ValueError(f"Optimization failed: {problem.status}")
|
|
49
|
+
|
|
50
|
+
shares_result = shares.value.astype(int)
|
|
51
|
+
return TradeBatch(
|
|
52
|
+
[
|
|
53
|
+
trade.normalize_target(target_shares=shares_result[index])
|
|
54
|
+
for index, trade in enumerate(self.batch.trades)
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def floor_trade_share(self):
|
|
59
|
+
return TradeBatch(
|
|
60
|
+
[trade.normalize_target(target_shares=math.floor(trade.target_shares)) for trade in self.batch.trades]
|
|
61
|
+
)
|