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
@@ -52,16 +52,10 @@ class PortfolioRole(models.Model):
52
52
  return f"{self.role_type} {self.person.computed_str}"
53
53
 
54
54
  def save(self, *args, **kwargs):
55
- assert (
56
- self.role_type in [self.RoleType.MANAGER, self.RoleType.RISK_MANAGER] and not self.instrument
57
- ) or self.role_type in [
58
- self.RoleType.PORTFOLIO_MANAGER,
59
- self.RoleType.ANALYST,
60
- ], self.default_error_messages["manager"].format(model="instrument")
61
-
62
- assert (self.start and self.end and self.start < self.end) or (
63
- not self.start or not self.end
64
- ), self.default_error_messages["start_end"]
55
+ if self.role_type in [self.RoleType.MANAGER, self.RoleType.RISK_MANAGER] and self.instrument:
56
+ raise ValueError(self.default_error_messages["manager"].format(model="instrument"))
57
+ if self.start and self.end and self.start > self.end:
58
+ raise ValueError(self.default_error_messages["start_end"])
65
59
 
66
60
  super().save(*args, **kwargs)
67
61
 
@@ -259,9 +259,10 @@ class Claim(ReferenceIDMixin, WBModel):
259
259
  return f"{self.reference_id} {self.product.name} ({self.bank} - {self.shares:,} shares - {self.date}) "
260
260
 
261
261
  def save(self, *args, auto_match: bool = True, **kwargs):
262
- assert (
263
- self.shares is not None or self.nominal_amount is not None
264
- ), f"Either shares or nominal amount have to be provided. Shares={self.shares}, Nominal={self.nominal_amount}"
262
+ if self.shares is None and self.nominal_amount is None:
263
+ raise ValueError(
264
+ f"Either shares or nominal amount have to be provided. Shares={self.shares}, Nominal={self.nominal_amount}"
265
+ )
265
266
  if self.product:
266
267
  if self.shares is not None:
267
268
  self.nominal_amount = self.shares * self.product.share_price
@@ -447,7 +448,7 @@ class Claim(ReferenceIDMixin, WBModel):
447
448
  return self.can_approve()
448
449
 
449
450
  def auto_match(self) -> Trade | None:
450
- SHARES_EPSILON = 1 # share
451
+ shares_epsilon = 1 # share
451
452
  auto_match_trade = None
452
453
  # Obvious filtering
453
454
  trades = Trade.valid_customer_trade_objects.filter(
@@ -458,7 +459,7 @@ class Claim(ReferenceIDMixin, WBModel):
458
459
  trades = trades.filter(underlying_instrument=self.product)
459
460
  # Find trades by shares (or remaining to be claimed)
460
461
  trades = trades.filter(
461
- Q(diff_shares__lte=self.shares + SHARES_EPSILON) & Q(diff_shares__gte=self.shares - SHARES_EPSILON)
462
+ Q(diff_shares__lte=self.shares + shares_epsilon) & Q(diff_shares__gte=self.shares - shares_epsilon)
462
463
  )
463
464
  if trades.count() == 1:
464
465
  auto_match_trade = trades.first()
@@ -41,6 +41,7 @@ class DividendTransaction(TransactionMixin, ImportMixin, models.Model):
41
41
  )
42
42
 
43
43
  def save(self, *args, **kwargs):
44
+ self.pre_save()
44
45
  if not self.record_date and self.ex_date:
45
46
  self.record_date = self.ex_date
46
47
  elif self.record_date and not self.ex_date:
@@ -194,6 +194,10 @@ class Trade(TransactionMixin, ImportMixin, models.Model):
194
194
  # notification_email_template = "portfolio/email/trade_notification.html"
195
195
 
196
196
  def save(self, *args, **kwargs):
197
+ self.pre_save()
198
+ if not self.weighting and (total_asset_value := self.portfolio.get_total_asset_value(self.transaction_date)):
199
+ self.weighting = self.currency_fx_rate * self.price * self.shares / total_asset_value
200
+
197
201
  if abs(self.weighting) < 10e-6:
198
202
  self.weighting = Decimal("0")
199
203
  if not self.price:
@@ -71,6 +71,22 @@ class TransactionMixin(models.Model):
71
71
  ),
72
72
  db_persist=True,
73
73
  )
74
+ price_fx_portfolio = models.GeneratedField(
75
+ expression=models.F("currency_fx_rate") * models.F("price"),
76
+ output_field=models.DecimalField(
77
+ max_digits=20,
78
+ decimal_places=4,
79
+ ),
80
+ db_persist=True,
81
+ )
82
+ price_gross_fx_portfolio = models.GeneratedField(
83
+ expression=models.F("currency_fx_rate") * models.F("price_gross"),
84
+ output_field=models.DecimalField(
85
+ max_digits=20,
86
+ decimal_places=4,
87
+ ),
88
+ db_persist=True,
89
+ )
74
90
  total_value_fx_portfolio = models.GeneratedField(
75
91
  expression=models.F("currency_fx_rate") * models.F("price") * models.F("shares"),
76
92
  output_field=models.DecimalField(
@@ -100,14 +116,10 @@ class TransactionMixin(models.Model):
100
116
  self.price_gross = self.price
101
117
  elif self.price_gross is not None and self.price is None:
102
118
  self.price = self.price_gross
103
-
104
- def save(self, *args, **kwargs):
105
- self.pre_save()
106
119
  if self.currency_fx_rate is None:
107
120
  self.currency_fx_rate = self.underlying_instrument.currency.convert(
108
121
  self.value_date, self.portfolio.currency, exact_lookup=True
109
122
  )
110
- super().save(*args, **kwargs)
111
123
 
112
124
  class Meta:
113
125
  abstract = True
@@ -1,6 +1,17 @@
1
+ import logging
2
+ from datetime import date
3
+ from decimal import Decimal
4
+ from typing import Iterator
5
+
6
+ from celery import shared_task
7
+ from django.db.models import F, QuerySet, Window
8
+ from django.db.models.functions import RowNumber
9
+ from tqdm import tqdm
1
10
  from wbfdm.models import Instrument
2
11
 
3
- from wbportfolio.models import Index, Product
12
+ from wbportfolio.models import AssetPosition, Index, Order, OrderProposal, Portfolio, Product
13
+
14
+ logger = logging.getLogger("pms")
4
15
 
5
16
 
6
17
  def get_casted_portfolio_instrument(instrument: Instrument) -> Product | Index | None:
@@ -11,3 +22,91 @@ def get_casted_portfolio_instrument(instrument: Instrument) -> Product | Index |
11
22
  return Index.objects.get(id=instrument.id)
12
23
  except Index.DoesNotExist:
13
24
  return None
25
+
26
+
27
+ def get_adjusted_shares(old_shares: Decimal, old_price: Decimal, new_price: Decimal) -> Decimal:
28
+ return old_shares * (old_price / new_price)
29
+
30
+
31
+ def adjust_assets(qs: Iterator[AssetPosition], underlying_quote: Instrument):
32
+ objs = []
33
+ logger.info("adjusting asset positions...")
34
+ for a in qs:
35
+ old_price: Decimal = a.initial_price
36
+ a.initial_price = a.underlying_instrument = a.underlying_quote_price = None
37
+ a.underlying_quote = underlying_quote
38
+ a.pre_save()
39
+ if a.initial_shares and a.initial_price and old_price != a.initial_price:
40
+ a.initial_shares = get_adjusted_shares(a.initial_shares, old_price, a.initial_price)
41
+ objs.append(a)
42
+ AssetPosition.objects.bulk_update(
43
+ objs,
44
+ ["underlying_quote", "underlying_quote_price", "underlying_instrument", "initial_price", "initial_shares"],
45
+ batch_size=1000,
46
+ )
47
+
48
+
49
+ def adjust_orders(qs: Iterator[Order], underlying_quote: Instrument):
50
+ objs = []
51
+ logger.info("adjusting orders...")
52
+ for o in qs:
53
+ old_price: Decimal = o.price
54
+ o.underlying_instrument = underlying_quote
55
+ o.set_price()
56
+ if o.price and old_price != o.price and o.shares:
57
+ o.shares = get_adjusted_shares(o.shares, old_price, o.price)
58
+ objs.append(o)
59
+ Order.objects.bulk_update(objs, ["price", "shares", "underlying_instrument"], batch_size=1000)
60
+
61
+
62
+ def adjust_quote(
63
+ old_quote: Instrument,
64
+ new_quote: Instrument,
65
+ adjust_after: date | None = None,
66
+ only_portfolios: QuerySet[Portfolio] | None = None,
67
+ debug: bool = False,
68
+ ):
69
+ if old_quote.currency != new_quote.currency:
70
+ raise ValueError("cannot safely switch quotes that are not of the same currency")
71
+ assets_to_change = AssetPosition.objects.filter(underlying_quote=old_quote)
72
+ orders_to_change = Order.objects.filter(underlying_instrument=old_quote)
73
+ new_quote.import_prices()
74
+ if adjust_after:
75
+ assets_to_change = assets_to_change.filter(date__gt=adjust_after)
76
+ orders_to_change = orders_to_change.filter(value_date__gt=adjust_after)
77
+ if only_portfolios is not None:
78
+ assets_to_change = assets_to_change.filter(portfolio__in=only_portfolios)
79
+ orders_to_change = orders_to_change.filter(order_proposal__portfolio__in=only_portfolios)
80
+ if debug:
81
+ assets_to_change = tqdm(assets_to_change, total=assets_to_change.count())
82
+ orders_to_change = tqdm(orders_to_change, total=orders_to_change.count())
83
+
84
+ # gather the list of order proposal to replay (if the quote led to missing position, we want to replay it to correct automatically the issue)
85
+ latest_orders = orders_to_change.annotate(
86
+ row_number=Window(
87
+ expression=RowNumber(), partition_by=[F("order_proposal__portfolio")], order_by=F("value_date").desc()
88
+ )
89
+ ).filter(row_number=1)
90
+ order_proposals_to_replay = OrderProposal.objects.filter(
91
+ portfolio__is_manageable=True, id__in=latest_orders.values("order_proposal")
92
+ )
93
+
94
+ # Adjust assets to the new quote
95
+ adjust_assets(assets_to_change, new_quote)
96
+
97
+ # Adjust orders to the new quote
98
+ adjust_orders(orders_to_change, new_quote)
99
+
100
+ # replay latest order proposal
101
+ for op in order_proposals_to_replay:
102
+ op.replay(reapply_order_proposal=True)
103
+
104
+
105
+ @shared_task(queue="portfolio")
106
+ def adjust_quote_as_task(
107
+ old_quote_id: int, new_quote_id: int, adjust_after: date | None = None, only_portfolio_ids: list[int] | None = None
108
+ ):
109
+ old_quote = Instrument.objects.get(id=old_quote_id)
110
+ new_quote = Instrument.objects.get(id=new_quote_id)
111
+ only_portfolios = Portfolio.objects.filter(id__in=only_portfolio_ids) if only_portfolio_ids else None
112
+ adjust_quote(old_quote, new_quote, adjust_after=adjust_after, only_portfolios=only_portfolios)
@@ -9,6 +9,22 @@ class ExecutionStatus(TextChoices):
9
9
  FAILED = "FAILED", "Failed"
10
10
  UNKNOWN = "UNKNOWN", "Unknown"
11
11
 
12
+
13
+
14
+ class ExecutionInstruction(TextChoices):
15
+
16
+ MARKET_ON_CLOSE = "MARKET_ON_CLOSE", "Market On Close" # no parameter
17
+ GUARANTEED_MARKET_ON_CLOSE = "GUARANTEED_MARKET_ON_CLOSE", "Guaranteed Market On Close" # no parameter
18
+ GUARANTEED_MARKET_ON_OPEN = "GUARANTEED_MARKET_ON_OPEN", "Guaranteed Market On Open" # no parameter
19
+ GPW_MARKET_ON_CLOSE = "GPW_MARKET_ON_CLOSE", "GPW Market On Close" # no parameter
20
+ MARKET_ON_OPEN = "MARKET_ON_OPEN", "Market On Open" # no parameter
21
+ IN_LINE_WITH_VOLUME = "IN_LINE_WITH_VOLUME", "In Line With Volume" # 1 parameter "Percentage"
22
+ LIMIT_ORDER = "LIMIT_ORDER", "Limit Order" # 2 parameters "limit and cutoff"
23
+ VWAP = "VWAP", "VWAP" # 2 parameters
24
+ TWAP = "TWAP", "TWAP" # 2 paramters
25
+
26
+
27
+
12
28
  class RoutingException(Exception):
13
29
  def __init__(self, errors):
14
30
  # messages: a list of strings
@@ -22,21 +22,29 @@ class BaseCustodianAdapter(ABC):
22
22
  pass
23
23
 
24
24
  @abstractmethod
25
- def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
25
+ def is_valid(self) -> bool:
26
26
  """
27
- Return the rebalance status as a string (in the custodian format)
27
+ Check whether the given isin is valid and can be rebalanced
28
28
  """
29
29
  pass
30
30
 
31
31
  @abstractmethod
32
- def is_valid(self) -> bool:
32
+ def serialize_orders(self, orders: list[Order]) -> list[dict[str, str]]:
33
+ pass
34
+
35
+ @abstractmethod
36
+ def deserialize_items(self, items: list[dict[str, str]]) -> list[Order]:
37
+ pass
38
+
39
+ @abstractmethod
40
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
33
41
  """
34
- Check whether the given isin is valid and can be rebalanced
42
+ Return the rebalance status as a string (in the custodian format)
35
43
  """
36
44
  pass
37
45
 
38
46
  @abstractmethod
39
- def submit_rebalancing(self, orders: list[Order], as_draft: bool = True) -> tuple[list[Order], str]:
47
+ def submit_rebalancing(self, items: list[dict[str, str]], as_draft: bool = True) -> tuple[list[dict[str, str]], str]:
40
48
  """
41
49
  Submit a rebalance order for the certificate.
42
50
  """
@@ -50,7 +58,7 @@ class BaseCustodianAdapter(ABC):
50
58
  pass
51
59
 
52
60
  @abstractmethod
53
- def get_current_rebalancing(self) -> list[Order]:
61
+ def get_current_rebalancing(self) -> list[dict[str, str]]:
54
62
  """
55
63
  Fetch the current rebalance request details for a certificate.
56
64
  """
@@ -7,10 +7,16 @@ from requests import HTTPError
7
7
  from wbportfolio.api_clients.ubs import UBSNeoAPIClient
8
8
  from wbportfolio.pms.typing import Order
9
9
 
10
- from .. import ExecutionStatus, RoutingException
10
+ from .. import ExecutionInstruction, ExecutionStatus, RoutingException
11
11
  from . import BaseCustodianAdapter
12
12
 
13
- ASSET_CLASS_MAP = {Order.AssetType.EQUITY: "EQUITY"} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
13
+ logger = logging.getLogger("oms")
14
+
15
+
16
+ ASSET_CLASS_MAP = {
17
+ Order.AssetType.EQUITY: "EQUITY",
18
+ Order.AssetType.AMERICAN_DEPOSITORY_RECEIPT: "EQUITY",
19
+ } # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
14
20
  ASSET_CLASS_MAP_INV = {
15
21
  v: k for k, v in ASSET_CLASS_MAP.items()
16
22
  } # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
@@ -18,68 +24,31 @@ ASSET_CLASS_MAP_INV = {
18
24
  STATUS_MAP = {
19
25
  "Amend Pending": ExecutionStatus.PENDING,
20
26
  "Cancel Pending": ExecutionStatus.PENDING,
21
- "Cancelled": ExecutionStatus.PENDING,
22
- "Complete": ExecutionStatus.PENDING,
23
- "Complete (Order Cancelled)": ExecutionStatus.PENDING,
24
- "Complete (Partial Fill)": ExecutionStatus.PENDING,
25
- "In Draft": ExecutionStatus.PENDING,
27
+ "Cancelled": ExecutionStatus.CANCELLED,
28
+ "Complete": ExecutionStatus.COMPLETED,
29
+ "Complete (Order Cancelled)": ExecutionStatus.COMPLETED,
30
+ "Complete (Partial Fill)": ExecutionStatus.COMPLETED,
31
+ "In Draft": ExecutionStatus.IN_DRAFT,
26
32
  "Pending Approval": ExecutionStatus.PENDING,
27
33
  "Pending Execution": ExecutionStatus.PENDING,
28
- "Rebalance Cancelled": ExecutionStatus.PENDING,
29
- "Rebalance Cancelled (Executing partially)": ExecutionStatus.PENDING,
30
- "Rejected": ExecutionStatus.PENDING,
34
+ "Rebalance Cancelled": ExecutionStatus.CANCELLED,
35
+ "Rebalance Cancelled (Executing partially)": ExecutionStatus.CANCELLED,
36
+ "Rejected": ExecutionStatus.REJECTED,
31
37
  "Rejection Acknowledged": ExecutionStatus.PENDING,
32
38
  "Waiting for Response": ExecutionStatus.PENDING,
33
39
  }
34
- logger = logging.getLogger("oms")
35
-
36
-
37
- def _serialize_orders(orders: list[Order], default_execution_instruction=None) -> list[dict[str, str]]:
38
- items = []
39
- for order in orders:
40
- if order.refinitiv_identifier_code:
41
- identifier_type, identifier = "RIC", order.refinitiv_identifier_code
42
- elif order.bloomberg_ticker:
43
- identifier_type, identifier = "BBTICKER", order.bloomberg_ticker
44
- else:
45
- identifier_type, identifier = "SEDOL", order.sedol
46
- item = {
47
- "assetClass": ASSET_CLASS_MAP[order.asset_class],
48
- "identifierType": identifier_type,
49
- "identifier": identifier,
50
- "executionInstruction": order.execution_instruction
51
- if order.execution_instruction
52
- else default_execution_instruction,
53
- "userElementId": str(order.id),
54
- "tradeDate": order.trade_date.strftime("%Y-%m-%d"),
55
- }
56
- if order.shares:
57
- item["sharesToTrade"] = str(order.shares)
58
- else:
59
- item["targetWeight"] = str(order.target_weight)
60
- items.append(item)
61
- return items
62
-
63
-
64
- def _deserialize_items(items: list[dict[str, str]]):
65
- orders = []
66
- for item in items:
67
- orders.append(
68
- Order(
69
- id=item.get("userElementId"),
70
- asset_class=ASSET_CLASS_MAP_INV[item.get("assetClass")],
71
- refinitiv_identifier_code=item.get(
72
- "ric", item["identifier"] if item.get("identifierType") == "RIC" else None
73
- ),
74
- bloomberg_ticker=item["identifier"] if item.get("identifierType") == "BBTICKER" else None,
75
- sedol=item["identifier"] if item.get("identifierType") == "SEDOL" else None,
76
- trade_date=datetime.strptime(item.get("tradeDate"), "%Y-%m-%d"),
77
- target_weight=float(item["targetWeight"]) if "targetWeight" in item else None,
78
- shares=float(item["sharesToTrade"]) if "sharesToTrade" in item else None,
79
- execution_instruction=item.get("executionInstruction"),
80
- )
81
- )
82
- return orders
40
+ EXECUTION_INSTRUCTION_MAP = {
41
+ ExecutionInstruction.MARKET_ON_CLOSE: "MARKET_ON_CLOSE",
42
+ ExecutionInstruction.GUARANTEED_MARKET_ON_CLOSE: "GUARANTEED_MARKET_ON_CLOSE",
43
+ ExecutionInstruction.GUARANTEED_MARKET_ON_OPEN: "GUARANTEED_MARKET_ON_OPEN",
44
+ ExecutionInstruction.GPW_MARKET_ON_CLOSE: "GPW_MARKET_ON_CLOSE",
45
+ ExecutionInstruction.MARKET_ON_OPEN: "MARKET_ON_OPEN",
46
+ ExecutionInstruction.IN_LINE_WITH_VOLUME: "IN_LINE_WITH_VOLUME",
47
+ ExecutionInstruction.LIMIT_ORDER: "LIMIT_ORDER",
48
+ ExecutionInstruction.VWAP: "VWAP",
49
+ ExecutionInstruction.TWAP: "TWAP",
50
+ }
51
+ EXECUTION_INSTRUCTION_MAP_INV = {v: k for k, v in EXECUTION_INSTRUCTION_MAP.items()}
83
52
 
84
53
 
85
54
  class CustodianAdapter(BaseCustodianAdapter):
@@ -96,6 +65,70 @@ class CustodianAdapter(BaseCustodianAdapter):
96
65
  if self.raise_exception:
97
66
  raise RoutingException(errors)
98
67
 
68
+ def _serialize_execution_instruction(
69
+ self, execution_instruction: ExecutionInstruction, execution_parameters: dict
70
+ ):
71
+ repr = EXECUTION_INSTRUCTION_MAP[execution_instruction]
72
+ if execution_parameters:
73
+ if execution_instruction == ExecutionInstruction.IN_LINE_WITH_VOLUME:
74
+ repr += f':{execution_parameters["percent"]:.0f%}'
75
+ elif execution_instruction == ExecutionInstruction.LIMIT_ORDER:
76
+ repr += f':{execution_parameters["price"]:.1f}'
77
+ if good_for_date := execution_parameters.get("good_for_date"):
78
+ repr += f",{good_for_date}"
79
+ elif (
80
+ execution_instruction == ExecutionInstruction.VWAP
81
+ or execution_instruction == ExecutionInstruction.TWAP
82
+ ):
83
+ repr += f':{execution_parameters["period"]},{execution_parameters["time"]}'
84
+ return repr
85
+
86
+ def serialize_orders(self, orders: list[Order]) -> list[dict[str, str]]:
87
+ items = []
88
+ for order in orders:
89
+ if order.refinitiv_identifier_code:
90
+ identifier_type, identifier = "RIC", order.refinitiv_identifier_code
91
+ elif order.bloomberg_ticker:
92
+ identifier_type, identifier = "BBTICKER", order.bloomberg_ticker
93
+ else:
94
+ identifier_type, identifier = "SEDOL", order.sedol
95
+ item = {
96
+ "assetClass": ASSET_CLASS_MAP[order.asset_class],
97
+ "identifierType": identifier_type,
98
+ "identifier": identifier,
99
+ "executionInstruction": self._serialize_execution_instruction(
100
+ order.execution_instruction, order.execution_instruction_parameters
101
+ ),
102
+ "userElementId": str(order.id),
103
+ "tradeDate": order.trade_date.strftime("%Y-%m-%d"),
104
+ }
105
+ if order.shares:
106
+ item["sharesToTrade"] = str(order.shares)
107
+ else:
108
+ item["targetWeight"] = str(order.target_weight * 100)
109
+ items.append(item)
110
+ return items
111
+
112
+ def deserialize_items(self, items: list[dict[str, str]]):
113
+ orders = []
114
+ for item in items:
115
+ orders.append(
116
+ Order(
117
+ id=item.get("userElementId"),
118
+ asset_class=ASSET_CLASS_MAP_INV[item.get("assetClass")],
119
+ refinitiv_identifier_code=item.get(
120
+ "ric", item["identifier"] if item.get("identifierType") == "RIC" else None
121
+ ),
122
+ bloomberg_ticker=item["identifier"] if item.get("identifierType") == "BBTICKER" else None,
123
+ sedol=item["identifier"] if item.get("identifierType") == "SEDOL" else None,
124
+ trade_date=datetime.strptime(item.get("tradeDate"), "%Y-%m-%d"),
125
+ target_weight=float(item["targetWeight"]) / 100 if "targetWeight" in item else None,
126
+ shares=float(item["sharesToTrade"]) if "sharesToTrade" in item else None,
127
+ execution_instruction=EXECUTION_INSTRUCTION_MAP_INV[item["executionInstruction"]],
128
+ )
129
+ )
130
+ return orders
131
+
99
132
  def authenticate(self) -> bool:
100
133
  """
101
134
  Authenticate or renew tokens with the custodian API.
@@ -104,12 +137,6 @@ class CustodianAdapter(BaseCustodianAdapter):
104
137
  self.client = UBSNeoAPIClient(settings.UBS_NEO_API_TOKEN)
105
138
  return True
106
139
 
107
- def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
108
- res = self.client.get_rebalance_status_for_isin(self.isin)
109
- self._handle_response(res)
110
- status = res["rebalanceStatus"]
111
- return STATUS_MAP.get(status, ExecutionStatus.UNKNOWN), status
112
-
113
140
  def is_valid(self) -> bool:
114
141
  """
115
142
  Check whether the given isin is valid and can be rebalanced
@@ -129,17 +156,18 @@ class CustodianAdapter(BaseCustodianAdapter):
129
156
  logger.warning(f"Couldn't validate adapter: {str(e)}")
130
157
  return False
131
158
 
132
- def submit_rebalancing(self, orders: list[Order], as_draft: bool = True) -> tuple[list[Order], str]:
159
+ def submit_rebalancing(
160
+ self, items: list[dict[str, str]], as_draft: bool = True
161
+ ) -> tuple[list[dict[str, str]], str]:
133
162
  """
134
163
  Submit a rebalance order for the certificate.
135
164
  """
136
- items = _serialize_orders(orders, default_execution_instruction="MARKET_ON_CLOSE")
137
165
  if not as_draft:
138
166
  res = self.client.submit_rebalance(self.isin, items)
139
167
  else:
140
168
  res = self.client.save_draft(self.isin, items)
141
169
  self._handle_response(res)
142
- return _deserialize_items(res["rebalanceItems"]), res["message"]
170
+ return res["rebalanceItems"], res["message"]
143
171
 
144
172
  def cancel_current_rebalancing(self) -> bool:
145
173
  """
@@ -152,10 +180,16 @@ class CustodianAdapter(BaseCustodianAdapter):
152
180
  except (HTTPError, KeyError):
153
181
  return False
154
182
 
155
- def get_current_rebalancing(self) -> list[Order]:
183
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
184
+ res = self.client.get_rebalance_status_for_isin(self.isin)
185
+ self._handle_response(res)
186
+ status = res.get("rebalanceStatus", "")
187
+ return STATUS_MAP.get(status, ExecutionStatus.UNKNOWN), status
188
+
189
+ def get_current_rebalancing(self) -> list[dict[str, str]]:
156
190
  """
157
191
  Fetch the current rebalance request details for a certificate.
158
192
  """
159
193
  res = self.client.get_current_rebalance_request(self.isin)
160
194
  self._handle_response(res)
161
- return _deserialize_items(res["rebalanceItems"])
195
+ return res["rebalanceItems"]
@@ -0,0 +1,33 @@
1
+ from django.conf import settings
2
+
3
+ from wbportfolio.order_routing import ExecutionStatus
4
+ from wbportfolio.order_routing.adapters import BaseCustodianAdapter
5
+ from wbportfolio.pms.typing import Order
6
+
7
+
8
+ class Router:
9
+ def __init__(self, adapter: BaseCustodianAdapter):
10
+ self.adapter = adapter
11
+
12
+ @property
13
+ def submit_as_draft(self):
14
+ return getattr(settings, "DEBUG", True) or getattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
15
+
16
+ def submit_rebalancing(self, orders: list[Order]) -> tuple[list[Order], tuple[str, str]]:
17
+ """
18
+ Submit a rebalance order for the certificate.
19
+ """
20
+ items = self.adapter.serialize_orders(orders)
21
+ confirmed_items, msg = self.adapter.submit_rebalancing(items, as_draft=self.submit_as_draft)
22
+ status = ExecutionStatus.IN_DRAFT if self.submit_as_draft else ExecutionStatus.PENDING
23
+ return self.adapter.deserialize_items(confirmed_items), (status, msg)
24
+
25
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
26
+ return self.adapter.get_rebalance_status()
27
+
28
+ def cancel_rebalancing(self) -> bool:
29
+ return self.adapter.cancel_current_rebalancing()
30
+
31
+ def get_current_rebalancing_request(self) -> list[Order]:
32
+ items = self.adapter.get_current_rebalancing()
33
+ return self.adapter.deserialize_items(items)
@@ -0,0 +1,110 @@
1
+ from unittest.mock import MagicMock, PropertyMock, patch
2
+
3
+ import pytest
4
+ from django.conf import settings
5
+
6
+ from wbportfolio.order_routing import ExecutionStatus
7
+ from wbportfolio.order_routing.router import Router
8
+ from wbportfolio.pms.typing import Order
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_adapter():
13
+ adapter = MagicMock()
14
+ return adapter
15
+
16
+
17
+ @pytest.fixture
18
+ def router(mock_adapter):
19
+ return Router(adapter=mock_adapter)
20
+
21
+
22
+ def test_submit_as_draft_from_settings(monkeypatch, router):
23
+ # Test default True if settings attribute missing
24
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
25
+ monkeypatch.setattr(settings, "DEBUG", False)
26
+ assert router.submit_as_draft is True
27
+
28
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
29
+ monkeypatch.setattr(settings, "DEBUG", True)
30
+ assert router.submit_as_draft is True
31
+
32
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
33
+ monkeypatch.setattr(settings, "DEBUG", True)
34
+ assert router.submit_as_draft is True
35
+
36
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
37
+ monkeypatch.setattr(settings, "DEBUG", False)
38
+ assert router.submit_as_draft is False
39
+
40
+
41
+ @patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
42
+ def test_submit_rebalancing_calls_adapter_as_draft(mock_property, router, mock_adapter):
43
+ mock_property.return_value = True
44
+ orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
45
+ serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
46
+ confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
47
+ msg = "Success message"
48
+
49
+ mock_adapter.serialize_orders.return_value = serialized_orders
50
+ mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
51
+ mock_adapter.deserialize_items.return_value = orders
52
+
53
+ result_orders, (status, message) = router.submit_rebalancing(orders)
54
+ assert result_orders == orders
55
+ assert status == ExecutionStatus.IN_DRAFT
56
+ assert message == msg
57
+ mock_adapter.serialize_orders.assert_called_once_with(orders)
58
+ mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=True)
59
+ mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
60
+
61
+
62
+ @patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
63
+ def test_submit_rebalancing_calls_adapter(mock_property, router, mock_adapter):
64
+ mock_property.return_value = False
65
+ orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
66
+ serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
67
+ confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
68
+ msg = "Success message"
69
+
70
+ mock_adapter.serialize_orders.return_value = serialized_orders
71
+ mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
72
+ mock_adapter.deserialize_items.return_value = orders
73
+
74
+ result_orders, (status, message) = router.submit_rebalancing(orders)
75
+ assert result_orders == orders
76
+ assert status == ExecutionStatus.PENDING
77
+ assert message == msg
78
+ mock_adapter.serialize_orders.assert_called_once_with(orders)
79
+ mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=False)
80
+ mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
81
+
82
+
83
+ def test_get_rebalance_status_returns_adapter_status(router, mock_adapter):
84
+ expected_status = ExecutionStatus.PENDING
85
+ expected_msg = "Status message"
86
+ mock_adapter.get_rebalance_status.return_value = (expected_status, expected_msg)
87
+
88
+ status, msg = router.get_rebalance_status()
89
+ assert status == expected_status
90
+ assert msg == expected_msg
91
+ mock_adapter.get_rebalance_status.assert_called_once()
92
+
93
+
94
+ def test_cancel_rebalancing_returns_adapter_result(router, mock_adapter):
95
+ mock_adapter.cancel_current_rebalancing.return_value = True
96
+ result = router.cancel_rebalancing()
97
+ assert result is True
98
+ mock_adapter.cancel_current_rebalancing.assert_called_once()
99
+
100
+
101
+ def test_get_current_rebalancing_request_returns_deserialized(router, mock_adapter):
102
+ serialized_orders = ["order1", "order2"]
103
+ deserialized_orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
104
+ mock_adapter.get_current_rebalancing.return_value = serialized_orders
105
+ mock_adapter.deserialize_items.return_value = deserialized_orders
106
+
107
+ result = router.get_current_rebalancing_request()
108
+ assert result == deserialized_orders
109
+ mock_adapter.get_current_rebalancing.assert_called_once()
110
+ mock_adapter.deserialize_items.assert_called_once_with(serialized_orders)