wbportfolio 1.55.8__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 (128) hide show
  1. wbportfolio/admin/orders/order_proposals.py +2 -0
  2. wbportfolio/admin/orders/orders.py +2 -0
  3. wbportfolio/admin/portfolio.py +11 -5
  4. wbportfolio/api_clients/ubs.py +23 -11
  5. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  6. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  7. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  8. wbportfolio/contrib/company_portfolio/models.py +69 -39
  9. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  10. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  11. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  12. wbportfolio/factories/assets.py +1 -1
  13. wbportfolio/factories/orders/order_proposals.py +3 -1
  14. wbportfolio/factories/orders/orders.py +8 -3
  15. wbportfolio/factories/product_groups.py +3 -3
  16. wbportfolio/factories/products.py +3 -3
  17. wbportfolio/filters/assets.py +0 -1
  18. wbportfolio/filters/orders/order_proposals.py +3 -6
  19. wbportfolio/filters/portfolios.py +18 -1
  20. wbportfolio/filters/positions.py +0 -1
  21. wbportfolio/filters/transactions/fees.py +0 -2
  22. wbportfolio/filters/transactions/trades.py +0 -1
  23. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  24. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  25. wbportfolio/import_export/handlers/asset_position.py +9 -5
  26. wbportfolio/import_export/handlers/dividend.py +1 -1
  27. wbportfolio/import_export/handlers/fees.py +2 -2
  28. wbportfolio/import_export/handlers/trade.py +4 -4
  29. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  30. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  31. wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
  32. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  33. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  34. wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
  35. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  36. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  37. wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
  38. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  39. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  40. wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
  41. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  42. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  43. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  44. wbportfolio/import_export/parsers/ubs/equity.py +2 -1
  45. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  46. wbportfolio/import_export/resources/trades.py +1 -1
  47. wbportfolio/import_export/utils.py +3 -1
  48. wbportfolio/metric/backends/base.py +2 -2
  49. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  50. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  51. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  52. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  53. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  54. wbportfolio/models/adjustments.py +1 -1
  55. wbportfolio/models/asset.py +7 -3
  56. wbportfolio/models/builder.py +25 -5
  57. wbportfolio/models/custodians.py +3 -3
  58. wbportfolio/models/exceptions.py +1 -1
  59. wbportfolio/models/graphs/portfolio.py +1 -1
  60. wbportfolio/models/graphs/utils.py +11 -11
  61. wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
  62. wbportfolio/models/orders/order_proposals.py +620 -490
  63. wbportfolio/models/orders/orders.py +237 -75
  64. wbportfolio/models/portfolio.py +79 -18
  65. wbportfolio/models/portfolio_relationship.py +6 -0
  66. wbportfolio/models/products.py +3 -0
  67. wbportfolio/models/rebalancing.py +4 -1
  68. wbportfolio/models/roles.py +4 -10
  69. wbportfolio/models/transactions/claim.py +6 -5
  70. wbportfolio/models/transactions/dividends.py +1 -0
  71. wbportfolio/models/transactions/trades.py +4 -0
  72. wbportfolio/models/transactions/transactions.py +16 -4
  73. wbportfolio/models/utils.py +100 -1
  74. wbportfolio/order_routing/__init__.py +16 -0
  75. wbportfolio/order_routing/adapters/__init__.py +14 -6
  76. wbportfolio/order_routing/adapters/ubs.py +104 -70
  77. wbportfolio/order_routing/router.py +33 -0
  78. wbportfolio/order_routing/tests/test_router.py +110 -0
  79. wbportfolio/permissions.py +7 -0
  80. wbportfolio/pms/trading/__init__.py +0 -1
  81. wbportfolio/pms/trading/optimizer.py +61 -0
  82. wbportfolio/pms/typing.py +115 -103
  83. wbportfolio/rebalancing/models/composite.py +1 -1
  84. wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
  85. wbportfolio/risk_management/backends/__init__.py +1 -0
  86. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  87. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  88. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  89. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  90. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  91. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  92. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  93. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  94. wbportfolio/serializers/orders/order_proposals.py +6 -2
  95. wbportfolio/serializers/orders/orders.py +119 -26
  96. wbportfolio/serializers/transactions/claim.py +2 -2
  97. wbportfolio/tasks.py +42 -4
  98. wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
  99. wbportfolio/tests/models/test_portfolios.py +9 -9
  100. wbportfolio/tests/models/test_splits.py +1 -6
  101. wbportfolio/tests/models/test_utils.py +140 -0
  102. wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
  103. wbportfolio/tests/rebalancing/test_models.py +2 -2
  104. wbportfolio/tests/viewsets/test_products.py +1 -0
  105. wbportfolio/urls.py +1 -1
  106. wbportfolio/viewsets/charts/assets.py +8 -4
  107. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  108. wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
  109. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  110. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  111. wbportfolio/viewsets/esg.py +3 -5
  112. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
  113. wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
  114. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
  115. wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
  116. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
  117. wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
  118. wbportfolio/viewsets/orders/order_proposals.py +92 -21
  119. wbportfolio/viewsets/orders/orders.py +79 -26
  120. wbportfolio/viewsets/portfolios.py +24 -0
  121. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
  122. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
  123. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  124. wbportfolio/fdm/tasks.py +0 -42
  125. wbportfolio/models/orders/routing.py +0 -54
  126. wbportfolio/pms/trading/handler.py +0 -211
  127. /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
  128. {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
1
1
  # Import necessary modules
2
2
  from datetime import date, timedelta
3
3
  from decimal import Decimal
4
- from unittest.mock import call, patch
4
+ from unittest.mock import MagicMock, PropertyMock, call, patch
5
5
 
6
6
  import pytest
7
7
  from django.db.models import Sum
@@ -9,12 +9,20 @@ from faker import Faker
9
9
  from pandas._libs.tslibs.offsets import BDay, BusinessMonthEnd
10
10
 
11
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
12
14
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
13
15
  from wbportfolio.pms.typing import Position
14
16
 
15
17
  fake = Faker()
16
18
 
17
19
 
20
+ @pytest.fixture
21
+ def mock_adapter():
22
+ adapter = MagicMock()
23
+ return adapter
24
+
25
+
18
26
  # Mark tests to use Django's database
19
27
  @pytest.mark.django_db
20
28
  class TestOrderProposal:
@@ -36,45 +44,62 @@ class TestOrderProposal:
36
44
  assert order_proposal.check_evaluation_date == order_proposal.trade_date
37
45
 
38
46
  # Test the validated trading service functionality
39
- def test_validated_trading_service(self, order_proposal, asset_position_factory, order_factory):
47
+ def test_validated_trading_service(
48
+ self, order_proposal, asset_position_factory, instrument_price_factory, instrument_factory, order_factory
49
+ ):
40
50
  """
41
51
  Validate that the effective and target portfolios are correctly calculated.
42
52
  """
43
53
  effective_date = (order_proposal.trade_date - BDay(1)).date()
44
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
+
45
64
  # Create asset positions for testing
46
65
  a1 = asset_position_factory.create(
47
- portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.3")
66
+ portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.3"), underlying_instrument=i1
48
67
  )
49
68
  a2 = asset_position_factory.create(
50
- portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.7")
69
+ portfolio=order_proposal.portfolio, date=effective_date, weighting=Decimal("0.7"), underlying_instrument=i2
51
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()
52
76
 
53
77
  # Create orders for testing
54
78
  o1 = order_factory.create(
55
79
  order_proposal=order_proposal,
56
80
  weighting=Decimal("0.05"),
57
81
  portfolio=order_proposal.portfolio,
58
- underlying_instrument=a1.underlying_quote,
82
+ underlying_instrument=i1,
59
83
  )
60
84
  o2 = order_factory.create(
61
85
  order_proposal=order_proposal,
62
86
  weighting=Decimal("-0.05"),
63
87
  portfolio=order_proposal.portfolio,
64
- underlying_instrument=a2.underlying_quote,
88
+ underlying_instrument=i2,
65
89
  )
66
90
 
67
- r1 = o1.price / a1.initial_price - Decimal("1")
68
- r2 = o2.price / a2.initial_price - Decimal("1")
69
- p_return = a1.weighting * (Decimal("1") + r1) + a2.weighting * (Decimal("1") + r2)
70
91
  # Get the validated trading service
71
- trades = order_proposal.validated_trading_service.trades_batch.trades_map
92
+ trades = order_proposal.get_trades_batch().trades_map
72
93
  t1 = trades[a1.underlying_quote.id]
73
94
  t2 = trades[a2.underlying_quote.id]
74
95
 
75
96
  # Assert effective and target portfolios are as expected
76
- assert t1.previous_weight == a1.weighting
77
- assert t2.previous_weight == a2.weighting
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
+ )
78
103
  assert t1.target_weight == pytest.approx(
79
104
  a1.weighting * ((r1 + Decimal("1")) / p_return) + o1.weighting, abs=Decimal("1e-8")
80
105
  )
@@ -121,10 +146,10 @@ class TestOrderProposal:
121
146
  portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date - BDay(1)).date()
122
147
  )
123
148
  tp_previous_approve = order_proposal_factory.create(
124
- portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date - BDay(2)).date()
149
+ portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date - BDay(2)).date()
125
150
  )
126
151
  tp_next_approve = order_proposal_factory.create( # noqa
127
- portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date + BDay(1)).date()
152
+ portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date + BDay(1)).date()
128
153
  )
129
154
 
130
155
  # The previous valid order proposal should be the approved one strictly before the current proposal
@@ -139,13 +164,13 @@ class TestOrderProposal:
139
164
  """
140
165
  tp = order_proposal_factory.create()
141
166
  tp_previous_approve = order_proposal_factory.create( # noqa
142
- portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date - BDay(1)).date()
167
+ portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date - BDay(1)).date()
143
168
  )
144
169
  tp_next_submit = order_proposal_factory.create( # noqa
145
170
  portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date + BDay(1)).date()
146
171
  )
147
172
  tp_next_approve = order_proposal_factory.create(
148
- portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date + BDay(2)).date()
173
+ portfolio=tp.portfolio, status=OrderProposal.Status.CONFIRMED, trade_date=(tp.trade_date + BDay(2)).date()
149
174
  )
150
175
 
151
176
  # The next valid order proposal should be the approved one strictly after the current proposal
@@ -218,12 +243,12 @@ class TestOrderProposal:
218
243
  t1 = order_factory.create(
219
244
  order_proposal=order_proposal,
220
245
  portfolio=order_proposal.portfolio,
221
- weighting=Decimal(0.2),
246
+ weighting=Decimal(0.05),
222
247
  )
223
248
  t2 = order_factory.create(
224
249
  order_proposal=order_proposal,
225
250
  portfolio=order_proposal.portfolio,
226
- weighting=Decimal(0.26),
251
+ weighting=Decimal(0.22),
227
252
  )
228
253
  t3 = order_factory.create(
229
254
  order_proposal=order_proposal,
@@ -232,26 +257,19 @@ class TestOrderProposal:
232
257
  )
233
258
 
234
259
  # Normalize orders
235
- order_proposal.normalize_orders()
260
+ order_proposal.normalize_orders(Decimal("0.18"))
236
261
 
237
262
  # Refresh orders from the database
238
263
  t1.refresh_from_db()
239
264
  t2.refresh_from_db()
240
265
  t3.refresh_from_db()
266
+ cash = order_proposal.orders.get(underlying_instrument__is_cash=True)
241
267
 
242
268
  # Expected normalized weights
243
- normalized_t1_weight = Decimal("0.33333333")
244
- normalized_t2_weight = Decimal("0.43333333")
245
- normalized_t3_weight = Decimal("0.23333333")
246
-
247
- # Calculate quantization error
248
- quantize_error = Decimal(1) - (normalized_t1_weight + normalized_t2_weight + normalized_t3_weight)
249
-
250
- # Assert quantization error exists and weights are normalized correctly
251
- assert quantize_error
252
- assert t1.weighting == normalized_t1_weight
253
- assert t2.weighting == normalized_t2_weight + quantize_error # Add quantize error to the largest position
254
- assert t3.weighting == normalized_t3_weight
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")
255
273
 
256
274
  # Test resetting orders
257
275
  def test_reset_orders(
@@ -390,15 +408,15 @@ class TestOrderProposal:
390
408
  mock_fct.return_value = iter([])
391
409
 
392
410
  # Create approved order proposals for testing
393
- tp0 = order_proposal_factory.create(status=OrderProposal.Status.APPLIED)
411
+ tp0 = order_proposal_factory.create(status=OrderProposal.Status.CONFIRMED)
394
412
  tp1 = order_proposal_factory.create(
395
413
  portfolio=tp0.portfolio,
396
- status=OrderProposal.Status.APPLIED,
414
+ status=OrderProposal.Status.CONFIRMED,
397
415
  trade_date=(tp0.trade_date + BusinessMonthEnd(1)).date(),
398
416
  )
399
417
  tp2 = order_proposal_factory.create(
400
418
  portfolio=tp0.portfolio,
401
- status=OrderProposal.Status.APPLIED,
419
+ status=OrderProposal.Status.CONFIRMED,
402
420
  trade_date=(tp1.trade_date + BusinessMonthEnd(1)).date(),
403
421
  )
404
422
 
@@ -422,7 +440,7 @@ class TestOrderProposal:
422
440
  mock_fct.assert_has_calls(expected_calls)
423
441
 
424
442
  # Test estimating shares for a trade
425
- @patch.object(Portfolio, "get_total_asset_value")
443
+ @patch.object(OrderProposal, "get_portfolio_total_asset_value")
426
444
  def test_get_estimated_shares(
427
445
  self, mock_fct, order_proposal, order_factory, instrument_price_factory, instrument_factory
428
446
  ):
@@ -449,7 +467,7 @@ class TestOrderProposal:
449
467
  == Decimal(1_000_000) * trade.weighting / underlying_quote_price.net_value
450
468
  )
451
469
 
452
- @patch.object(Portfolio, "get_total_asset_value")
470
+ @patch.object(OrderProposal, "get_portfolio_total_asset_value")
453
471
  def test_get_estimated_target_cash(self, mock_fct, order_proposal, order_factory, cash_factory):
454
472
  order_proposal.portfolio.only_weighting = False
455
473
  order_proposal.portfolio.save()
@@ -500,17 +518,25 @@ class TestOrderProposal:
500
518
  instrument.exchange.save()
501
519
  assert order_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
502
520
 
503
- def test_submit_round_lot_size(self, order_proposal, order_factory, instrument_factory):
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
+
504
529
  order_proposal.portfolio.only_weighting = False
505
530
  order_proposal.portfolio.save()
506
- instrument = instrument_factory.create()
531
+ instrument = price.instrument
507
532
  instrument.round_lot_size = 100
508
533
  instrument.save()
509
534
  trade = order_factory.create(
510
- underlying_instrument=instrument,
511
- shares=70,
535
+ shares=initial_shares,
512
536
  order_proposal=order_proposal,
513
537
  weighting=Decimal("1.0"),
538
+ underlying_instrument=price.instrument,
539
+ price=net_value,
514
540
  )
515
541
  warnings = order_proposal.submit()
516
542
  order_proposal.save()
@@ -520,18 +546,34 @@ class TestOrderProposal:
520
546
  trade.refresh_from_db()
521
547
  assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
522
548
 
523
- def test_submit_round_fractional_shares(self, order_proposal, order_factory):
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
+
524
559
  order_proposal.portfolio.only_weighting = False
525
560
  order_proposal.portfolio.save()
561
+
526
562
  trade = order_factory.create(
527
- shares=5.6,
563
+ shares=Decimal("5.6"),
528
564
  order_proposal=order_proposal,
529
565
  weighting=Decimal("1.0"),
566
+ underlying_instrument=price.instrument,
567
+ price=net_value,
530
568
  )
531
569
  order_proposal.submit()
532
570
  order_proposal.save()
533
571
  trade.refresh_from_db()
534
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
535
577
 
536
578
  def test_ex_post(
537
579
  self, instrument_factory, asset_position_factory, instrument_price_factory, order_proposal_factory, portfolio
@@ -667,12 +709,11 @@ class TestOrderProposal:
667
709
  )
668
710
  order_proposal.submit()
669
711
  order_proposal.approve()
670
- order_proposal.apply(replay=False)
712
+ order_proposal.confirm()
671
713
  order_proposal.save()
672
714
 
673
715
  draft_tp = order_proposal_factory.create(portfolio=order_proposal.portfolio, trade_date=date.today() - BDay(1))
674
716
  assert not Order.objects.filter(order_proposal=draft_tp).exists()
675
-
676
717
  order_proposal.replay()
677
718
 
678
719
  assert Order.objects.filter(order_proposal=draft_tp).count() == 1
@@ -695,6 +736,37 @@ class TestOrderProposal:
695
736
  assert order.shares == Decimal(0)
696
737
  assert order.weighting == Decimal(0)
697
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
+
698
770
  def test_reset_order_use_desired_target_weight(self, order_proposal, order_factory):
699
771
  order1 = order_factory.create(
700
772
  order_proposal=order_proposal, weighting=Decimal("0.5"), desired_target_weight=Decimal("0.7")
@@ -735,15 +807,240 @@ class TestOrderProposal:
735
807
  order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.2"))
736
808
 
737
809
  order_proposal.reset_orders()
738
- assert order_proposal.get_orders().aggregate(s=Sum("target_weight"))["s"] == Decimal(
739
- "0.98"
740
- ), "The total target weight leftover does not equal the stored total cash weight"
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"
741
813
 
742
814
  def test_convert_to_portfolio_always_100percent(self, order_proposal, order_factory):
743
815
  o1 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.5"))
744
816
  o2 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
745
817
 
746
- portfolio = order_proposal.convert_to_portfolio()
818
+ portfolio = order_proposal._get_default_effective_portfolio(include_delta_weight=True)
747
819
  assert portfolio.positions_map[o1.underlying_instrument.id].weighting == Decimal("0.5")
748
820
  assert portfolio.positions_map[o2.underlying_instrument.id].weighting == Decimal("0.3")
749
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
@@ -924,13 +924,13 @@ class TestPortfolioModel(PortfolioTestMixin):
924
924
 
925
925
  analytic_portfolio = portfolio.get_analytic_portfolio(weekday)
926
926
  assert analytic_portfolio.weights.tolist() == [float(a1.weighting), float(a2.weighting)]
927
- expected_X = pd.DataFrame(
927
+ expected_x = pd.DataFrame(
928
928
  [[float(p11.net_value / p10.net_value - Decimal(1)), float(p21.net_value / p20.net_value - Decimal(1))]],
929
929
  columns=[i1.id, i2.id],
930
930
  index=[(weekday + BDay(1)).date()],
931
931
  )
932
- expected_X.index = pd.to_datetime(expected_X.index)
933
- pd.testing.assert_frame_equal(analytic_portfolio.X, expected_X, check_names=False, check_freq=False)
932
+ expected_x.index = pd.to_datetime(expected_x.index)
933
+ pd.testing.assert_frame_equal(analytic_portfolio.X, expected_x, check_names=False, check_freq=False)
934
934
 
935
935
  def test_get_total_asset_value(self, weekday, portfolio, asset_position_factory):
936
936
  a1 = asset_position_factory.create(date=weekday, portfolio=portfolio)
@@ -1010,7 +1010,7 @@ class TestPortfolioModel(PortfolioTestMixin):
1010
1010
  assert len(res) == 1
1011
1011
  assert a.date == weekday
1012
1012
  assert a.underlying_quote == instrument
1013
- assert a.underlying_quote_price == None
1013
+ assert a.underlying_quote_price is None
1014
1014
  assert a.initial_price == p.net_value
1015
1015
  assert a.weighting == pytest.approx(weights[instrument.id], abs=Decimal(10e-6))
1016
1016
  assert a.currency_fx_rate_portfolio_to_usd == fx_portfolio
@@ -1053,11 +1053,11 @@ class TestPortfolioModel(PortfolioTestMixin):
1053
1053
  assert next(gen)[0] == middle_date, "Drifting weight with a non automatic rebalancer stops the iteration"
1054
1054
  try:
1055
1055
  next(gen)
1056
- assert False, "the next iteration should stop and return the rebalancing"
1056
+ raise AssertionError("the next iteration should stop and return the rebalancing")
1057
1057
  except StopIteration as e:
1058
1058
  rebalancing_order_proposal = e.value
1059
1059
  assert rebalancing_order_proposal.trade_date == rebalancing_date
1060
- assert rebalancing_order_proposal.status == "APPROVED"
1060
+ assert rebalancing_order_proposal.status == "PENDING"
1061
1061
 
1062
1062
  # we expect a equally rebalancing (default) so both orders needs to be created
1063
1063
  orders = rebalancing_order_proposal.get_orders()
@@ -1066,10 +1066,10 @@ class TestPortfolioModel(PortfolioTestMixin):
1066
1066
  assert t1._target_weight == Decimal("0.5")
1067
1067
  assert t2._target_weight == Decimal("0.5")
1068
1068
 
1069
+ rebalancing_order_proposal.approve()
1070
+ rebalancing_order_proposal.save()
1069
1071
  # we approve the rebalancing order proposal
1070
1072
  assert rebalancing_order_proposal.status == "APPROVED"
1071
- rebalancing_order_proposal.apply(replay=False)
1072
- rebalancing_order_proposal.save()
1073
1073
 
1074
1074
  # check that the rebalancing was applied and position reflect that
1075
1075
  assert portfolio.assets.get(date=rebalancing_date, underlying_instrument=i1).weighting == Decimal("0.5")
@@ -1131,7 +1131,7 @@ class TestPortfolioModel(PortfolioTestMixin):
1131
1131
  PortfolioPortfolioThroughModel.objects.create(
1132
1132
  portfolio=lookthrough_portfolio,
1133
1133
  dependency_portfolio=primary_portfolio,
1134
- type=PortfolioPortfolioThroughModel.Type.PRIMARY,
1134
+ type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH,
1135
1135
  )
1136
1136
 
1137
1137
  primary_portfolio.handle_controlling_portfolio_change_at_date(weekday)
@@ -14,10 +14,6 @@ fake = Faker()
14
14
 
15
15
  @pytest.mark.django_db
16
16
  class TestAdjustmentModel:
17
- @pytest.fixture()
18
- def applied_adjustment(self):
19
- return AdjustmentFactory.create(status=Adjustment.Status.APPLIED)
20
-
21
17
  @pytest.fixture()
22
18
  def old_adjustment(self):
23
19
  return AdjustmentFactory.create(status=Adjustment.Status.PENDING, date=fake.past_date())
@@ -205,7 +201,7 @@ class TestAdjustmentModel:
205
201
  post_adjustment_on_prices(adjustment.id)
206
202
  a1.refresh_from_db()
207
203
  adjustment.refresh_from_db()
208
- a1.applied_adjustment == adjustment
204
+ assert a1.applied_adjustment == adjustment
209
205
  assert adjustment.status == Adjustment.Status.APPLIED
210
206
 
211
207
  @patch("wbportfolio.models.adjustments.send_notification")
@@ -230,5 +226,4 @@ class TestAdjustmentModel:
230
226
  mock_check_fct.return_value = False
231
227
  post_adjustment_on_prices(adjustment.id)
232
228
  adjustment.refresh_from_db()
233
- mock_delay_fct.call_args[0] == user_porftolio_manager.id
234
229
  assert adjustment.status == Adjustment.Status.PENDING