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