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.
- wbportfolio/admin/orders/order_proposals.py +2 -0
- wbportfolio/admin/orders/orders.py +2 -0
- wbportfolio/admin/portfolio.py +11 -5
- wbportfolio/api_clients/ubs.py +23 -11
- wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/models.py +69 -39
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/contrib/company_portfolio/serializers.py +32 -22
- wbportfolio/contrib/company_portfolio/tasks.py +12 -1
- wbportfolio/factories/assets.py +1 -1
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +8 -3
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/orders/order_proposals.py +3 -6
- wbportfolio/filters/portfolios.py +18 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/backends/ubs/__init__.py +1 -0
- wbportfolio/import_export/backends/ubs/trade.py +48 -0
- wbportfolio/import_export/handlers/asset_position.py +9 -5
- wbportfolio/import_export/handlers/dividend.py +1 -1
- wbportfolio/import_export/handlers/fees.py +2 -2
- wbportfolio/import_export/handlers/trade.py +4 -4
- wbportfolio/import_export/parsers/default_mapping.py +1 -1
- wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/fees.py +2 -2
- wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
- wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
- wbportfolio/import_export/parsers/leonteq/trade.py +2 -1
- wbportfolio/import_export/parsers/natixis/equity.py +22 -4
- wbportfolio/import_export/parsers/natixis/utils.py +13 -19
- wbportfolio/import_export/parsers/sg_lux/equity.py +4 -3
- wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
- wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
- wbportfolio/import_export/parsers/societe_generale/strategy.py +3 -3
- wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
- wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
- wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
- wbportfolio/import_export/parsers/ubs/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/resources/trades.py +1 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
- wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
- wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
- wbportfolio/models/adjustments.py +1 -1
- wbportfolio/models/asset.py +7 -3
- wbportfolio/models/builder.py +25 -5
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.py +1 -1
- wbportfolio/models/graphs/portfolio.py +1 -1
- wbportfolio/models/graphs/utils.py +11 -11
- wbportfolio/models/mixins/liquidity_stress_test.py +1 -1
- wbportfolio/models/orders/order_proposals.py +620 -490
- wbportfolio/models/orders/orders.py +237 -75
- wbportfolio/models/portfolio.py +79 -18
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +4 -1
- wbportfolio/models/roles.py +4 -10
- wbportfolio/models/transactions/claim.py +6 -5
- wbportfolio/models/transactions/dividends.py +1 -0
- wbportfolio/models/transactions/trades.py +4 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/models/utils.py +100 -1
- wbportfolio/order_routing/__init__.py +16 -0
- wbportfolio/order_routing/adapters/__init__.py +14 -6
- wbportfolio/order_routing/adapters/ubs.py +104 -70
- wbportfolio/order_routing/router.py +33 -0
- wbportfolio/order_routing/tests/test_router.py +110 -0
- wbportfolio/permissions.py +7 -0
- wbportfolio/pms/trading/__init__.py +0 -1
- wbportfolio/pms/trading/optimizer.py +61 -0
- wbportfolio/pms/typing.py +115 -103
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/__init__.py +1 -0
- wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
- wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
- wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
- wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
- wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
- wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
- wbportfolio/serializers/orders/order_proposals.py +6 -2
- wbportfolio/serializers/orders/orders.py +119 -26
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tasks.py +42 -4
- wbportfolio/tests/models/orders/test_order_proposals.py +345 -48
- wbportfolio/tests/models/test_portfolios.py +9 -9
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/models/test_utils.py +140 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +1 -1
- wbportfolio/tests/rebalancing/test_models.py +2 -2
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/urls.py +1 -1
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/assets.py +1 -1
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/esg.py +3 -5
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +74 -15
- wbportfolio/viewsets/orders/configs/buttons/orders.py +104 -0
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +30 -30
- wbportfolio/viewsets/orders/configs/displays/orders.py +56 -17
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +1 -1
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +10 -8
- wbportfolio/viewsets/orders/order_proposals.py +92 -21
- wbportfolio/viewsets/orders/orders.py +79 -26
- wbportfolio/viewsets/portfolios.py +24 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +125 -115
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
- wbportfolio/fdm/tasks.py +0 -42
- wbportfolio/models/orders/routing.py +0 -54
- wbportfolio/pms/trading/handler.py +0 -211
- /wbportfolio/{fdm → order_routing/tests}/__init__.py +0 -0
- {wbportfolio-1.55.8.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
wbportfolio/models/roles.py
CHANGED
|
@@ -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
|
-
|
|
56
|
-
self.
|
|
57
|
-
|
|
58
|
-
self.
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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 +
|
|
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()
|
|
@@ -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
|
wbportfolio/models/utils.py
CHANGED
|
@@ -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
|
|
25
|
+
def is_valid(self) -> bool:
|
|
26
26
|
"""
|
|
27
|
-
|
|
27
|
+
Check whether the given isin is valid and can be rebalanced
|
|
28
28
|
"""
|
|
29
29
|
pass
|
|
30
30
|
|
|
31
31
|
@abstractmethod
|
|
32
|
-
def
|
|
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
|
-
|
|
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,
|
|
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[
|
|
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
|
-
|
|
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.
|
|
22
|
-
"Complete": ExecutionStatus.
|
|
23
|
-
"Complete (Order Cancelled)": ExecutionStatus.
|
|
24
|
-
"Complete (Partial Fill)": ExecutionStatus.
|
|
25
|
-
"In Draft": ExecutionStatus.
|
|
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.
|
|
29
|
-
"Rebalance Cancelled (Executing partially)": ExecutionStatus.
|
|
30
|
-
"Rejected": ExecutionStatus.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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)
|