wbportfolio 1.54.22__py2.py3-none-any.whl → 1.55.0__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/indexes.py +1 -1
- wbportfolio/admin/product_groups.py +1 -1
- wbportfolio/admin/products.py +2 -1
- wbportfolio/admin/rebalancing.py +1 -1
- wbportfolio/api_clients/__init__.py +0 -0
- wbportfolio/api_clients/ubs.py +150 -0
- wbportfolio/factories/orders/order_proposals.py +3 -1
- wbportfolio/factories/orders/orders.py +10 -2
- wbportfolio/factories/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/filters/assets.py +10 -2
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/order_proposals.py +58 -0
- wbportfolio/filters/portfolios.py +20 -0
- wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
- wbportfolio/import_export/backends/ubs/fees.py +10 -20
- wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
- wbportfolio/import_export/backends/utils.py +0 -17
- wbportfolio/import_export/handlers/asset_position.py +1 -1
- wbportfolio/import_export/handlers/orders.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/equity.py +1 -1
- wbportfolio/import_export/parsers/societe_generale/strategy.py +2 -2
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/asset.py +6 -20
- wbportfolio/models/builder.py +74 -31
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +549 -167
- wbportfolio/models/orders/orders.py +24 -11
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +77 -41
- wbportfolio/models/products.py +9 -0
- wbportfolio/models/rebalancing.py +6 -6
- wbportfolio/models/transactions/transactions.py +10 -6
- wbportfolio/order_routing/__init__.py +19 -0
- wbportfolio/order_routing/adapters/__init__.py +57 -0
- wbportfolio/order_routing/adapters/ubs.py +161 -0
- wbportfolio/pms/trading/handler.py +4 -1
- wbportfolio/pms/typing.py +62 -8
- wbportfolio/rebalancing/models/composite.py +1 -1
- wbportfolio/rebalancing/models/equally_weighted.py +3 -3
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -1
- wbportfolio/rebalancing/models/model_portfolio.py +9 -10
- wbportfolio/serializers/orders/order_proposals.py +25 -21
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/conftest.py +6 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +90 -30
- wbportfolio/tests/models/test_imports.py +5 -3
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/test_products.py +11 -0
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/tests/signals.py +0 -10
- wbportfolio/tests/tests.py +2 -0
- wbportfolio/viewsets/__init__.py +7 -4
- wbportfolio/viewsets/assets.py +1 -215
- wbportfolio/viewsets/charts/__init__.py +6 -1
- wbportfolio/viewsets/charts/assets.py +341 -155
- wbportfolio/viewsets/configs/buttons/products.py +32 -2
- wbportfolio/viewsets/configs/display/assets.py +6 -19
- wbportfolio/viewsets/configs/display/products.py +1 -1
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/menu/__init__.py +1 -0
- wbportfolio/viewsets/configs/menu/orders.py +11 -0
- wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +66 -12
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +55 -9
- wbportfolio/viewsets/orders/configs/displays/orders.py +13 -0
- wbportfolio/viewsets/orders/order_proposals.py +47 -7
- wbportfolio/viewsets/orders/orders.py +31 -29
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/METADATA +3 -1
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/RECORD +78 -68
- wbportfolio/viewsets/signals.py +0 -43
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.22.dist-info → wbportfolio-1.55.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from requests import HTTPError
|
|
6
|
+
|
|
7
|
+
from wbportfolio.api_clients.ubs import UBSNeoAPIClient
|
|
8
|
+
from wbportfolio.pms.typing import Order
|
|
9
|
+
|
|
10
|
+
from .. import ExecutionStatus, RoutingException
|
|
11
|
+
from . import BaseCustodianAdapter
|
|
12
|
+
|
|
13
|
+
ASSET_CLASS_MAP = {Order.AssetType.EQUITY: "EQUITY"} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
|
|
14
|
+
ASSET_CLASS_MAP_INV = {
|
|
15
|
+
v: k for k, v in ASSET_CLASS_MAP.items()
|
|
16
|
+
} # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
|
|
17
|
+
|
|
18
|
+
STATUS_MAP = {
|
|
19
|
+
"Amend Pending": ExecutionStatus.PENDING,
|
|
20
|
+
"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,
|
|
26
|
+
"Pending Approval": ExecutionStatus.PENDING,
|
|
27
|
+
"Pending Execution": ExecutionStatus.PENDING,
|
|
28
|
+
"Rebalance Cancelled": ExecutionStatus.PENDING,
|
|
29
|
+
"Rebalance Cancelled (Executing partially)": ExecutionStatus.PENDING,
|
|
30
|
+
"Rejected": ExecutionStatus.PENDING,
|
|
31
|
+
"Rejection Acknowledged": ExecutionStatus.PENDING,
|
|
32
|
+
"Waiting for Response": ExecutionStatus.PENDING,
|
|
33
|
+
}
|
|
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
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CustodianAdapter(BaseCustodianAdapter):
|
|
86
|
+
client: UBSNeoAPIClient
|
|
87
|
+
|
|
88
|
+
def __init__(self, *args, raise_exception: bool = False, **kwargs):
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
self.raise_exception = raise_exception
|
|
91
|
+
|
|
92
|
+
def _handle_response(self, res):
|
|
93
|
+
logger.info(res["message"])
|
|
94
|
+
if errors := res.get("errors"):
|
|
95
|
+
logger.warning(errors)
|
|
96
|
+
if self.raise_exception:
|
|
97
|
+
raise RoutingException(errors)
|
|
98
|
+
|
|
99
|
+
def authenticate(self) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Authenticate or renew tokens with the custodian API.
|
|
102
|
+
Raises an exception if authentication fails.
|
|
103
|
+
"""
|
|
104
|
+
self.client = UBSNeoAPIClient(settings.UBS_NEO_API_TOKEN)
|
|
105
|
+
return True
|
|
106
|
+
|
|
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
|
+
def is_valid(self) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Check whether the given isin is valid and can be rebalanced
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
status_res = self.client.get_rebalance_service_status()
|
|
120
|
+
|
|
121
|
+
isin_res = self.client.get_rebalance_status_for_isin(self.isin)
|
|
122
|
+
self._handle_response(status_res)
|
|
123
|
+
self._handle_response(isin_res)
|
|
124
|
+
return (
|
|
125
|
+
status_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
126
|
+
and isin_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
127
|
+
)
|
|
128
|
+
except (HTTPError, KeyError) as e:
|
|
129
|
+
logger.warning(f"Couldn't validate adapter: {str(e)}")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def submit_rebalancing(self, orders: list[Order], as_draft: bool = True) -> tuple[list[Order], str]:
|
|
133
|
+
"""
|
|
134
|
+
Submit a rebalance order for the certificate.
|
|
135
|
+
"""
|
|
136
|
+
items = _serialize_orders(orders, default_execution_instruction="MARKET_ON_CLOSE")
|
|
137
|
+
if not as_draft:
|
|
138
|
+
res = self.client.submit_rebalance(self.isin, items)
|
|
139
|
+
else:
|
|
140
|
+
res = self.client.save_draft(self.isin, items)
|
|
141
|
+
self._handle_response(res)
|
|
142
|
+
return _deserialize_items(res["rebalanceItems"]), res["message"]
|
|
143
|
+
|
|
144
|
+
def cancel_current_rebalancing(self) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
Cancel an existing rebalance order identified by ISIN.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
res = self.client.cancel_rebalance(self.isin)
|
|
150
|
+
self._handle_response(res)
|
|
151
|
+
return res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
|
|
152
|
+
except (HTTPError, KeyError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def get_current_rebalancing(self) -> list[Order]:
|
|
156
|
+
"""
|
|
157
|
+
Fetch the current rebalance request details for a certificate.
|
|
158
|
+
"""
|
|
159
|
+
res = self.client.get_current_rebalance_request(self.isin)
|
|
160
|
+
self._handle_response(res)
|
|
161
|
+
return _deserialize_items(res["rebalanceItems"])
|
|
@@ -87,7 +87,6 @@ class TradingService:
|
|
|
87
87
|
total_target_weight
|
|
88
88
|
)
|
|
89
89
|
self._effective_portfolio = effective_portfolio
|
|
90
|
-
self._target_portfolio = target_portfolio
|
|
91
90
|
|
|
92
91
|
@property
|
|
93
92
|
def errors(self) -> list[str]:
|
|
@@ -146,12 +145,15 @@ class TradingService:
|
|
|
146
145
|
previous_weight = target_weight = 0
|
|
147
146
|
effective_shares = target_shares = 0
|
|
148
147
|
daily_return = 0
|
|
148
|
+
is_cash = False
|
|
149
149
|
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
150
150
|
previous_weight = effective_pos.weighting
|
|
151
151
|
effective_shares = effective_pos.shares
|
|
152
152
|
daily_return = effective_pos.daily_return
|
|
153
|
+
is_cash = effective_pos.is_cash
|
|
153
154
|
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
154
155
|
target_weight = target_pos.weighting
|
|
156
|
+
is_cash = target_pos.is_cash
|
|
155
157
|
if target_pos.shares is not None:
|
|
156
158
|
target_shares = target_pos.shares
|
|
157
159
|
trade = Trade(
|
|
@@ -167,6 +169,7 @@ class TradingService:
|
|
|
167
169
|
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
168
170
|
daily_return=Decimal(daily_return),
|
|
169
171
|
portfolio_contribution=effective_portfolio.portfolio_contribution,
|
|
172
|
+
is_cash=is_cash,
|
|
170
173
|
)
|
|
171
174
|
trades.append(trade)
|
|
172
175
|
return TradeBatch(trades)
|
wbportfolio/pms/typing.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
from dataclasses import asdict, dataclass, field, fields
|
|
3
|
+
from datetime import date
|
|
2
4
|
from datetime import date as date_lib
|
|
3
5
|
from decimal import Decimal
|
|
4
6
|
|
|
@@ -13,7 +15,7 @@ class Valuation:
|
|
|
13
15
|
outstanding_shares: Decimal = Decimal(0)
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
@dataclass(
|
|
18
|
+
@dataclass()
|
|
17
19
|
class Position:
|
|
18
20
|
underlying_instrument: int
|
|
19
21
|
weighting: Decimal
|
|
@@ -38,6 +40,10 @@ class Position:
|
|
|
38
40
|
volume_usd: float = None
|
|
39
41
|
price: float = None
|
|
40
42
|
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
self.daily_return = round(self.daily_return, 16)
|
|
45
|
+
self.weighting = round(self.weighting, 8)
|
|
46
|
+
|
|
41
47
|
def __add__(self, other):
|
|
42
48
|
return Position(
|
|
43
49
|
weighting=self.weighting + other.weighting,
|
|
@@ -58,6 +64,7 @@ class Portfolio:
|
|
|
58
64
|
|
|
59
65
|
def __post_init__(self):
|
|
60
66
|
positions_map = {}
|
|
67
|
+
|
|
61
68
|
for pos in self.positions:
|
|
62
69
|
if pos.underlying_instrument in positions_map:
|
|
63
70
|
positions_map[pos.underlying_instrument] += pos
|
|
@@ -67,7 +74,7 @@ class Portfolio:
|
|
|
67
74
|
|
|
68
75
|
@property
|
|
69
76
|
def total_weight(self):
|
|
70
|
-
return
|
|
77
|
+
return sum([pos.weighting for pos in self.positions])
|
|
71
78
|
|
|
72
79
|
@property
|
|
73
80
|
def total_shares(self):
|
|
@@ -75,7 +82,7 @@ class Portfolio:
|
|
|
75
82
|
|
|
76
83
|
@property
|
|
77
84
|
def portfolio_contribution(self) -> Decimal:
|
|
78
|
-
return sum(map(lambda pos: pos.weighting * (Decimal("1") + pos.daily_return), self.positions))
|
|
85
|
+
return round(sum(map(lambda pos: pos.weighting * (Decimal("1") + pos.daily_return), self.positions)), 16)
|
|
79
86
|
|
|
80
87
|
def to_df(self):
|
|
81
88
|
return pd.DataFrame([asdict(pos) for pos in self.positions])
|
|
@@ -91,6 +98,28 @@ class Portfolio:
|
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
@dataclass(frozen=True)
|
|
101
|
+
class Order:
|
|
102
|
+
class AssetType(enum.Enum):
|
|
103
|
+
EQUITY = "EQUITY"
|
|
104
|
+
|
|
105
|
+
id: int | str
|
|
106
|
+
trade_date: date
|
|
107
|
+
target_weight: float
|
|
108
|
+
|
|
109
|
+
# Instrument identifier
|
|
110
|
+
asset_class: AssetType
|
|
111
|
+
refinitiv_identifier_code: str | None = None
|
|
112
|
+
bloomberg_ticker: str | None = None
|
|
113
|
+
sedol: str | None = None
|
|
114
|
+
|
|
115
|
+
weighting: float | None = None
|
|
116
|
+
target_shares: float | None = None
|
|
117
|
+
shares: float | None = None
|
|
118
|
+
execution_instruction: str | None = None
|
|
119
|
+
comment: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass()
|
|
94
123
|
class Trade:
|
|
95
124
|
underlying_instrument: int
|
|
96
125
|
instrument_type: int
|
|
@@ -104,9 +133,19 @@ class Trade:
|
|
|
104
133
|
target_shares: Decimal = Decimal("0")
|
|
105
134
|
daily_return: Decimal = Decimal("0")
|
|
106
135
|
portfolio_contribution: Decimal = Decimal("1")
|
|
136
|
+
quantization_error: Decimal = Decimal("0")
|
|
137
|
+
|
|
107
138
|
id: int | None = None
|
|
108
139
|
is_cash: bool = False
|
|
109
140
|
|
|
141
|
+
def __post_init__(self):
|
|
142
|
+
self.previous_weight = round(self.previous_weight, 8)
|
|
143
|
+
# ensure a trade target weight cannot be lower than 0
|
|
144
|
+
self.target_weight = round(self.target_weight, 8)
|
|
145
|
+
if self.target_weight < Decimal("1e-7"):
|
|
146
|
+
self.target_weight = Decimal("0")
|
|
147
|
+
self.daily_return = round(self.daily_return, 16)
|
|
148
|
+
|
|
110
149
|
def __add__(self, other):
|
|
111
150
|
return Trade(
|
|
112
151
|
underlying_instrument=self.underlying_instrument,
|
|
@@ -137,12 +176,20 @@ class Trade:
|
|
|
137
176
|
attrs.update(kwargs)
|
|
138
177
|
return Trade(**attrs)
|
|
139
178
|
|
|
179
|
+
def set_quantization_error(self, quantization_error: Decimal):
|
|
180
|
+
self.quantization_error = quantization_error
|
|
181
|
+
self.target_weight += quantization_error
|
|
182
|
+
|
|
140
183
|
@property
|
|
141
184
|
def effective_weight(self) -> Decimal:
|
|
142
185
|
return (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
186
|
+
round(
|
|
187
|
+
self.previous_weight * (self.daily_return + 1) / self.portfolio_contribution
|
|
188
|
+
if self.portfolio_contribution
|
|
189
|
+
else self.previous_weight,
|
|
190
|
+
8,
|
|
191
|
+
)
|
|
192
|
+
+ self.quantization_error
|
|
146
193
|
)
|
|
147
194
|
|
|
148
195
|
@property
|
|
@@ -184,6 +231,9 @@ class TradeBatch:
|
|
|
184
231
|
|
|
185
232
|
def __post_init__(self):
|
|
186
233
|
trade_map = {}
|
|
234
|
+
total_effective_weight = self.total_effective_weight
|
|
235
|
+
if total_effective_weight and (quant_error := Decimal("1") - self.total_effective_weight):
|
|
236
|
+
self.largest_effective_order.set_quantization_error(quant_error)
|
|
187
237
|
for trade in self.trades:
|
|
188
238
|
if trade.underlying_instrument in trade_map:
|
|
189
239
|
trade_map[trade.underlying_instrument] += trade
|
|
@@ -191,13 +241,17 @@ class TradeBatch:
|
|
|
191
241
|
trade_map[trade.underlying_instrument] = trade
|
|
192
242
|
object.__setattr__(self, "trades_map", trade_map)
|
|
193
243
|
|
|
244
|
+
@property
|
|
245
|
+
def largest_effective_order(self) -> Trade:
|
|
246
|
+
return max(self.trades, key=lambda obj: obj.previous_weight)
|
|
247
|
+
|
|
194
248
|
@property
|
|
195
249
|
def total_target_weight(self) -> Decimal:
|
|
196
|
-
return
|
|
250
|
+
return sum([trade.target_weight for trade in self.trades], Decimal("0"))
|
|
197
251
|
|
|
198
252
|
@property
|
|
199
253
|
def total_effective_weight(self) -> Decimal:
|
|
200
|
-
return
|
|
254
|
+
return sum([trade.effective_weight for trade in self.trades], Decimal("0"))
|
|
201
255
|
|
|
202
256
|
@property
|
|
203
257
|
def total_abs_delta_weight(self) -> Decimal:
|
|
@@ -20,7 +20,7 @@ class CompositeRebalancing(AbstractRebalancingModel):
|
|
|
20
20
|
"""
|
|
21
21
|
try:
|
|
22
22
|
latest_order_proposal = self.portfolio.order_proposals.filter(
|
|
23
|
-
status="
|
|
23
|
+
status="APPLIED", trade_date__lt=self.trade_date
|
|
24
24
|
).latest("trade_date")
|
|
25
25
|
return {
|
|
26
26
|
v["underlying_instrument"]: v["target_weight"]
|
|
@@ -24,11 +24,11 @@ class EquallyWeightedRebalancing(AbstractRebalancingModel):
|
|
|
24
24
|
|
|
25
25
|
def get_target_portfolio(self) -> Portfolio:
|
|
26
26
|
positions = []
|
|
27
|
-
|
|
28
|
-
for position in
|
|
27
|
+
assets = list(filter(lambda p: not p.is_cash, self.effective_portfolio.positions))
|
|
28
|
+
for position in assets:
|
|
29
29
|
positions.append(
|
|
30
30
|
position.copy(
|
|
31
|
-
weighting=Decimal(1 /
|
|
31
|
+
weighting=Decimal(1 / len(assets)), date=self.trade_date, asset_valuation_date=self.trade_date
|
|
32
32
|
)
|
|
33
33
|
)
|
|
34
34
|
return Portfolio(positions)
|
|
@@ -22,7 +22,7 @@ from wbportfolio.rebalancing.decorators import register
|
|
|
22
22
|
@register("Market Capitalization Rebalancing")
|
|
23
23
|
class MarketCapitalizationRebalancing(AbstractRebalancingModel):
|
|
24
24
|
TARGET_CURRENCY: str = "USD"
|
|
25
|
-
MIN_WEIGHT: float =
|
|
25
|
+
MIN_WEIGHT: float = 1e-5 # we allow only weight of minimum 0.01%
|
|
26
26
|
|
|
27
27
|
def __init__(
|
|
28
28
|
self,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from wbfdm.models import InstrumentPrice
|
|
2
|
-
|
|
3
1
|
from wbportfolio.pms.typing import Portfolio
|
|
4
2
|
from wbportfolio.rebalancing.base import AbstractRebalancingModel
|
|
5
3
|
from wbportfolio.rebalancing.decorators import register
|
|
@@ -9,6 +7,11 @@ from wbportfolio.rebalancing.decorators import register
|
|
|
9
7
|
class ModelPortfolioRebalancing(AbstractRebalancingModel):
|
|
10
8
|
def __init__(self, *args, **kwargs):
|
|
11
9
|
super().__init__(*args, **kwargs)
|
|
10
|
+
self.value_date = (
|
|
11
|
+
self.trade_date
|
|
12
|
+
if self.model_portfolio.assets.filter(date=self.trade_date).exists()
|
|
13
|
+
else self.last_effective_date
|
|
14
|
+
)
|
|
12
15
|
|
|
13
16
|
@property
|
|
14
17
|
def model_portfolio_rel(self):
|
|
@@ -20,19 +23,15 @@ class ModelPortfolioRebalancing(AbstractRebalancingModel):
|
|
|
20
23
|
|
|
21
24
|
@property
|
|
22
25
|
def assets(self):
|
|
23
|
-
return self.model_portfolio.get_positions(self.
|
|
26
|
+
return self.model_portfolio.get_positions(self.value_date) if self.model_portfolio else []
|
|
24
27
|
|
|
25
28
|
def is_valid(self) -> bool:
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
len(self.assets) > 0
|
|
29
|
-
and InstrumentPrice.objects.filter(date=self.trade_date, instrument__in=instruments).exists()
|
|
30
|
-
)
|
|
29
|
+
return len(self.assets) > 0
|
|
31
30
|
|
|
32
31
|
def get_target_portfolio(self) -> Portfolio:
|
|
33
32
|
positions = []
|
|
34
33
|
for asset in self.assets:
|
|
35
|
-
asset.date = self.
|
|
36
|
-
asset.asset_valuation_date = self.
|
|
34
|
+
asset.date = self.value_date
|
|
35
|
+
asset.asset_valuation_date = self.value_date
|
|
37
36
|
positions.append(asset._build_dto())
|
|
38
37
|
return Portfolio(positions=tuple(positions))
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from decimal import Decimal
|
|
2
|
-
|
|
3
1
|
from django.contrib.messages import warning
|
|
4
2
|
from django.core.exceptions import ValidationError
|
|
5
3
|
from rest_framework.reverse import reverse
|
|
6
4
|
from wbcore import serializers as wb_serializers
|
|
7
|
-
from wbcore.serializers import
|
|
5
|
+
from wbcore.contrib.directory.serializers import PersonRepresentationSerializer
|
|
6
|
+
from wbcore.serializers import CharField, DefaultFromView
|
|
8
7
|
|
|
9
8
|
from wbportfolio.models import OrderProposal, Portfolio, RebalancingModel
|
|
10
9
|
|
|
@@ -20,31 +19,28 @@ class OrderProposalRepresentationSerializer(wb_serializers.RepresentationSeriali
|
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
22
|
+
_portfolio = PortfolioRepresentationSerializer(source="portfolio")
|
|
23
23
|
rebalancing_model = wb_serializers.PrimaryKeyRelatedField(queryset=RebalancingModel.objects.all(), required=False)
|
|
24
24
|
_rebalancing_model = RebalancingModelRepresentationSerializer(source="rebalancing_model")
|
|
25
25
|
target_portfolio = wb_serializers.PrimaryKeyRelatedField(
|
|
26
26
|
queryset=Portfolio.objects.all(), write_only=True, required=False
|
|
27
27
|
)
|
|
28
28
|
_target_portfolio = PortfolioRepresentationSerializer(source="target_portfolio")
|
|
29
|
-
total_cash_weight = wb_serializers.DecimalField(
|
|
30
|
-
default=0,
|
|
31
|
-
decimal_places=4,
|
|
32
|
-
max_digits=5,
|
|
33
|
-
write_only=True,
|
|
34
|
-
required=False,
|
|
35
|
-
precision=4,
|
|
36
|
-
percent=True,
|
|
37
|
-
label="Target Cash",
|
|
38
|
-
help_text="Enter the desired percentage for the cash component. The remaining percentage (100% minus this value) will be allocated to total target weighting. Default is 0%.",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
29
|
trade_date = wb_serializers.DateField(
|
|
42
30
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
43
31
|
)
|
|
32
|
+
_creator = PersonRepresentationSerializer(source="creator")
|
|
33
|
+
_approver = PersonRepresentationSerializer(source="approver")
|
|
34
|
+
execution_status_repr = wb_serializers.SerializerMethodField(label="Status", field_class=CharField, read_only=True)
|
|
35
|
+
|
|
36
|
+
def get_execution_status_repr(self, obj):
|
|
37
|
+
repr = obj.execution_status
|
|
38
|
+
if obj.execution_status_detail:
|
|
39
|
+
repr += f" (Custodian: {obj.execution_status_detail})"
|
|
40
|
+
return repr
|
|
44
41
|
|
|
45
42
|
def create(self, validated_data):
|
|
46
43
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
47
|
-
total_cash_weight = validated_data.pop("total_cash_weight", Decimal("0.0"))
|
|
48
44
|
rebalancing_model = validated_data.get("rebalancing_model", None)
|
|
49
45
|
if request := self.context.get("request"):
|
|
50
46
|
validated_data["creator"] = request.user.profile
|
|
@@ -59,9 +55,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
59
55
|
)
|
|
60
56
|
|
|
61
57
|
try:
|
|
62
|
-
obj.reset_orders(
|
|
63
|
-
target_portfolio=target_portfolio_dto, total_target_weight=Decimal("1.0") - total_cash_weight
|
|
64
|
-
)
|
|
58
|
+
obj.reset_orders(target_portfolio=target_portfolio_dto)
|
|
65
59
|
except ValidationError as e:
|
|
66
60
|
if request := self.context.get("request"):
|
|
67
61
|
warning(request, str(e), extra_tags="auto_close=0")
|
|
@@ -70,7 +64,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
70
64
|
@wb_serializers.register_only_instance_resource()
|
|
71
65
|
def additional_resources(self, instance, request, user, **kwargs):
|
|
72
66
|
res = {}
|
|
73
|
-
if instance.status == OrderProposal.Status.
|
|
67
|
+
if instance.status == OrderProposal.Status.APPLIED:
|
|
74
68
|
res["replay"] = reverse("wbportfolio:orderproposal-replay", args=[instance.id], request=request)
|
|
75
69
|
if instance.status == OrderProposal.Status.DRAFT:
|
|
76
70
|
res["reset"] = reverse("wbportfolio:orderproposal-reset", args=[instance.id], request=request)
|
|
@@ -86,18 +80,28 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
86
80
|
class Meta:
|
|
87
81
|
model = OrderProposal
|
|
88
82
|
only_fsm_transition_on_instance = True
|
|
83
|
+
percent_fields = ["total_cash_weight"]
|
|
89
84
|
fields = (
|
|
90
85
|
"id",
|
|
91
86
|
"trade_date",
|
|
87
|
+
"portfolio",
|
|
88
|
+
"_portfolio",
|
|
92
89
|
"total_cash_weight",
|
|
93
90
|
"comment",
|
|
94
91
|
"status",
|
|
95
92
|
"min_order_value",
|
|
96
|
-
"portfolio",
|
|
97
93
|
"_rebalancing_model",
|
|
98
94
|
"rebalancing_model",
|
|
99
95
|
"target_portfolio",
|
|
100
96
|
"_target_portfolio",
|
|
97
|
+
"creator",
|
|
98
|
+
"approver",
|
|
99
|
+
"_creator",
|
|
100
|
+
"_approver",
|
|
101
|
+
"execution_status",
|
|
102
|
+
"execution_status_detail",
|
|
103
|
+
"execution_comment",
|
|
104
|
+
"execution_status_repr",
|
|
101
105
|
"_additional_resources",
|
|
102
106
|
)
|
|
103
107
|
|
|
@@ -42,6 +42,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
42
42
|
underlying_instrument_ticker = wb_serializers.CharField(read_only=True)
|
|
43
43
|
underlying_instrument_refinitiv_identifier_code = wb_serializers.CharField(read_only=True)
|
|
44
44
|
underlying_instrument_instrument_type = wb_serializers.CharField(read_only=True)
|
|
45
|
+
underlying_instrument_exchange = wb_serializers.CharField(read_only=True)
|
|
45
46
|
|
|
46
47
|
target_weight = wb_serializers.DecimalField(
|
|
47
48
|
max_digits=Order.ORDER_WEIGHTING_PRECISION + 1,
|
|
@@ -83,8 +84,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
83
84
|
weighting = data.get("weighting", self.instance.weighting if self.instance else Decimal(0.0))
|
|
84
85
|
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
85
86
|
weighting = target_weight - effective_weight
|
|
86
|
-
|
|
87
|
-
weighting = target_weight - effective_weight
|
|
87
|
+
data["desired_target_weight"] = target_weight
|
|
88
88
|
if weighting >= 0:
|
|
89
89
|
data["order_type"] = "BUY"
|
|
90
90
|
else:
|
|
@@ -124,6 +124,7 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
124
124
|
"underlying_instrument_ticker",
|
|
125
125
|
"underlying_instrument_refinitiv_identifier_code",
|
|
126
126
|
"underlying_instrument_instrument_type",
|
|
127
|
+
"underlying_instrument_exchange",
|
|
127
128
|
"order_type",
|
|
128
129
|
"comment",
|
|
129
130
|
"effective_weight",
|
|
@@ -138,6 +139,8 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
138
139
|
"target_total_value_fx_portfolio",
|
|
139
140
|
"portfolio_currency",
|
|
140
141
|
"has_warnings",
|
|
142
|
+
"execution_confirmed",
|
|
143
|
+
"execution_comment",
|
|
141
144
|
)
|
|
142
145
|
|
|
143
146
|
|
|
@@ -11,8 +11,8 @@ class AggregatedAssetPositionModelSerializer(wb_serializers.ModelSerializer):
|
|
|
11
11
|
sum_total_value = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=4)
|
|
12
12
|
weighting = wb_serializers.DecimalField(
|
|
13
13
|
read_only=True,
|
|
14
|
-
max_digits=
|
|
15
|
-
decimal_places=
|
|
14
|
+
max_digits=9,
|
|
15
|
+
decimal_places=8,
|
|
16
16
|
percent=True,
|
|
17
17
|
decorators=[{"position": "right", "value": "%"}],
|
|
18
18
|
)
|
|
@@ -49,7 +49,7 @@ class RebalancerModelSerializer(wb_serializers.ModelSerializer):
|
|
|
49
49
|
"computed_str",
|
|
50
50
|
"_rebalancing_model",
|
|
51
51
|
"rebalancing_model",
|
|
52
|
-
"
|
|
52
|
+
"apply_order_proposal_automatically",
|
|
53
53
|
"activation_date",
|
|
54
54
|
"frequency",
|
|
55
55
|
"frequency_repr",
|
wbportfolio/tests/conftest.py
CHANGED
|
@@ -70,7 +70,7 @@ from wbportfolio.factories import (
|
|
|
70
70
|
RebalancerFactory,
|
|
71
71
|
RebalancingModelFactory
|
|
72
72
|
)
|
|
73
|
-
|
|
73
|
+
from wbreport.factories import ReportFactory, ReportAssetFactory, ReportVersionFactory, ReportClassFactory, ReportCategoryFactory
|
|
74
74
|
from wbcore.tests.conftest import * # isort:skip
|
|
75
75
|
|
|
76
76
|
register(AccountFactory)
|
|
@@ -147,7 +147,11 @@ register(DailyPortfolioCashFlowFactory)
|
|
|
147
147
|
|
|
148
148
|
register(AccountReconciliationFactory)
|
|
149
149
|
register(AccountReconciliationLineFactory)
|
|
150
|
-
|
|
150
|
+
register(ReportFactory)
|
|
151
|
+
register(ReportAssetFactory)
|
|
152
|
+
register(ReportVersionFactory)
|
|
153
|
+
register(ReportClassFactory)
|
|
154
|
+
register(ReportCategoryFactory)
|
|
151
155
|
|
|
152
156
|
pre_migrate.connect(app_pre_migration, sender=apps.get_app_config("wbportfolio"))
|
|
153
157
|
from .signals import * # noqa: F401
|