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.

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