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