wbportfolio 1.54.23__py2.py3-none-any.whl → 1.55.1__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/portfolios.py +1 -1
- wbportfolio/factories/rebalancing.py +1 -1
- 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/trade.py +2 -2
- 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/customer_trade.py +7 -5
- wbportfolio/import_export/parsers/ubs/equity.py +1 -1
- wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
- wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
- wbportfolio/models/builder.py +70 -25
- wbportfolio/models/mixins/instruments.py +7 -0
- wbportfolio/models/orders/order_proposals.py +512 -161
- wbportfolio/models/orders/orders.py +20 -10
- wbportfolio/models/orders/routing.py +54 -0
- wbportfolio/models/portfolio.py +76 -41
- 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 -0
- 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 +23 -3
- wbportfolio/serializers/orders/orders.py +5 -2
- wbportfolio/serializers/positions.py +2 -2
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/tests/models/orders/test_order_proposals.py +29 -15
- wbportfolio/tests/models/test_imports.py +7 -6
- wbportfolio/tests/models/test_portfolios.py +57 -23
- wbportfolio/tests/models/transactions/test_rebalancing.py +2 -2
- wbportfolio/tests/rebalancing/test_models.py +3 -5
- wbportfolio/viewsets/charts/assets.py +4 -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 +56 -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 +42 -4
- wbportfolio/viewsets/orders/orders.py +33 -31
- wbportfolio/viewsets/portfolios.py +3 -3
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/RECORD +64 -54
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.23.dist-info → wbportfolio-1.55.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -145,12 +145,15 @@ class TradingService:
|
|
|
145
145
|
previous_weight = target_weight = 0
|
|
146
146
|
effective_shares = target_shares = 0
|
|
147
147
|
daily_return = 0
|
|
148
|
+
is_cash = False
|
|
148
149
|
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
149
150
|
previous_weight = effective_pos.weighting
|
|
150
151
|
effective_shares = effective_pos.shares
|
|
151
152
|
daily_return = effective_pos.daily_return
|
|
153
|
+
is_cash = effective_pos.is_cash
|
|
152
154
|
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
153
155
|
target_weight = target_pos.weighting
|
|
156
|
+
is_cash = target_pos.is_cash
|
|
154
157
|
if target_pos.shares is not None:
|
|
155
158
|
target_shares = target_pos.shares
|
|
156
159
|
trade = Trade(
|
|
@@ -166,6 +169,7 @@ class TradingService:
|
|
|
166
169
|
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
167
170
|
daily_return=Decimal(daily_return),
|
|
168
171
|
portfolio_contribution=effective_portfolio.portfolio_contribution,
|
|
172
|
+
is_cash=is_cash,
|
|
169
173
|
)
|
|
170
174
|
trades.append(trade)
|
|
171
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))
|
|
@@ -2,7 +2,8 @@ from django.contrib.messages import warning
|
|
|
2
2
|
from django.core.exceptions import ValidationError
|
|
3
3
|
from rest_framework.reverse import reverse
|
|
4
4
|
from wbcore import serializers as wb_serializers
|
|
5
|
-
from wbcore.serializers import
|
|
5
|
+
from wbcore.contrib.directory.serializers import PersonRepresentationSerializer
|
|
6
|
+
from wbcore.serializers import CharField, DefaultFromView
|
|
6
7
|
|
|
7
8
|
from wbportfolio.models import OrderProposal, Portfolio, RebalancingModel
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ class OrderProposalRepresentationSerializer(wb_serializers.RepresentationSeriali
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
22
|
+
_portfolio = PortfolioRepresentationSerializer(source="portfolio")
|
|
21
23
|
rebalancing_model = wb_serializers.PrimaryKeyRelatedField(queryset=RebalancingModel.objects.all(), required=False)
|
|
22
24
|
_rebalancing_model = RebalancingModelRepresentationSerializer(source="rebalancing_model")
|
|
23
25
|
target_portfolio = wb_serializers.PrimaryKeyRelatedField(
|
|
@@ -27,6 +29,15 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
27
29
|
trade_date = wb_serializers.DateField(
|
|
28
30
|
read_only=lambda view: not view.new_mode, default=DefaultFromView("default_trade_date")
|
|
29
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
|
|
30
41
|
|
|
31
42
|
def create(self, validated_data):
|
|
32
43
|
target_portfolio = validated_data.pop("target_portfolio", None)
|
|
@@ -53,7 +64,7 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
53
64
|
@wb_serializers.register_only_instance_resource()
|
|
54
65
|
def additional_resources(self, instance, request, user, **kwargs):
|
|
55
66
|
res = {}
|
|
56
|
-
if instance.status == OrderProposal.Status.
|
|
67
|
+
if instance.status == OrderProposal.Status.APPLIED:
|
|
57
68
|
res["replay"] = reverse("wbportfolio:orderproposal-replay", args=[instance.id], request=request)
|
|
58
69
|
if instance.status == OrderProposal.Status.DRAFT:
|
|
59
70
|
res["reset"] = reverse("wbportfolio:orderproposal-reset", args=[instance.id], request=request)
|
|
@@ -73,15 +84,24 @@ class OrderProposalModelSerializer(wb_serializers.ModelSerializer):
|
|
|
73
84
|
fields = (
|
|
74
85
|
"id",
|
|
75
86
|
"trade_date",
|
|
87
|
+
"portfolio",
|
|
88
|
+
"_portfolio",
|
|
76
89
|
"total_cash_weight",
|
|
77
90
|
"comment",
|
|
78
91
|
"status",
|
|
79
92
|
"min_order_value",
|
|
80
|
-
"portfolio",
|
|
81
93
|
"_rebalancing_model",
|
|
82
94
|
"rebalancing_model",
|
|
83
95
|
"target_portfolio",
|
|
84
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",
|
|
85
105
|
"_additional_resources",
|
|
86
106
|
)
|
|
87
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",
|
|
@@ -118,13 +118,13 @@ class TestOrderProposal:
|
|
|
118
118
|
"""
|
|
119
119
|
tp = order_proposal_factory.create()
|
|
120
120
|
tp_previous_submit = order_proposal_factory.create( # noqa
|
|
121
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
121
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date - BDay(1)).date()
|
|
122
122
|
)
|
|
123
123
|
tp_previous_approve = order_proposal_factory.create(
|
|
124
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
124
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date - BDay(2)).date()
|
|
125
125
|
)
|
|
126
126
|
tp_next_approve = order_proposal_factory.create( # noqa
|
|
127
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
127
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date + BDay(1)).date()
|
|
128
128
|
)
|
|
129
129
|
|
|
130
130
|
# The previous valid order proposal should be the approved one strictly before the current proposal
|
|
@@ -139,13 +139,13 @@ class TestOrderProposal:
|
|
|
139
139
|
"""
|
|
140
140
|
tp = order_proposal_factory.create()
|
|
141
141
|
tp_previous_approve = order_proposal_factory.create( # noqa
|
|
142
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
142
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date - BDay(1)).date()
|
|
143
143
|
)
|
|
144
144
|
tp_next_submit = order_proposal_factory.create( # noqa
|
|
145
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
145
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.PENDING, trade_date=(tp.trade_date + BDay(1)).date()
|
|
146
146
|
)
|
|
147
147
|
tp_next_approve = order_proposal_factory.create(
|
|
148
|
-
portfolio=tp.portfolio, status=OrderProposal.Status.
|
|
148
|
+
portfolio=tp.portfolio, status=OrderProposal.Status.APPLIED, trade_date=(tp.trade_date + BDay(2)).date()
|
|
149
149
|
)
|
|
150
150
|
|
|
151
151
|
# The next valid order proposal should be the approved one strictly after the current proposal
|
|
@@ -255,11 +255,12 @@ class TestOrderProposal:
|
|
|
255
255
|
|
|
256
256
|
# Test resetting orders
|
|
257
257
|
def test_reset_orders(
|
|
258
|
-
self, order_proposal, instrument_factory,
|
|
258
|
+
self, order_proposal, instrument_factory, cash_factory, instrument_price_factory, asset_position_factory
|
|
259
259
|
):
|
|
260
260
|
"""
|
|
261
261
|
Verify orders are correctly reset based on effective and target portfolios.
|
|
262
262
|
"""
|
|
263
|
+
cash = cash_factory.create()
|
|
263
264
|
effective_date = order_proposal.last_effective_date
|
|
264
265
|
|
|
265
266
|
# Create instruments for testing
|
|
@@ -389,15 +390,15 @@ class TestOrderProposal:
|
|
|
389
390
|
mock_fct.return_value = iter([])
|
|
390
391
|
|
|
391
392
|
# Create approved order proposals for testing
|
|
392
|
-
tp0 = order_proposal_factory.create(status=OrderProposal.Status.
|
|
393
|
+
tp0 = order_proposal_factory.create(status=OrderProposal.Status.APPLIED)
|
|
393
394
|
tp1 = order_proposal_factory.create(
|
|
394
395
|
portfolio=tp0.portfolio,
|
|
395
|
-
status=OrderProposal.Status.
|
|
396
|
+
status=OrderProposal.Status.APPLIED,
|
|
396
397
|
trade_date=(tp0.trade_date + BusinessMonthEnd(1)).date(),
|
|
397
398
|
)
|
|
398
399
|
tp2 = order_proposal_factory.create(
|
|
399
400
|
portfolio=tp0.portfolio,
|
|
400
|
-
status=OrderProposal.Status.
|
|
401
|
+
status=OrderProposal.Status.APPLIED,
|
|
401
402
|
trade_date=(tp1.trade_date + BusinessMonthEnd(1)).date(),
|
|
402
403
|
)
|
|
403
404
|
|
|
@@ -499,9 +500,10 @@ class TestOrderProposal:
|
|
|
499
500
|
instrument.exchange.save()
|
|
500
501
|
assert order_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
501
502
|
|
|
502
|
-
def test_submit_round_lot_size(self, order_proposal, order_factory,
|
|
503
|
+
def test_submit_round_lot_size(self, order_proposal, order_factory, instrument_factory):
|
|
503
504
|
order_proposal.portfolio.only_weighting = False
|
|
504
505
|
order_proposal.portfolio.save()
|
|
506
|
+
instrument = instrument_factory.create()
|
|
505
507
|
instrument.round_lot_size = 100
|
|
506
508
|
instrument.save()
|
|
507
509
|
trade = order_factory.create(
|
|
@@ -518,11 +520,10 @@ class TestOrderProposal:
|
|
|
518
520
|
trade.refresh_from_db()
|
|
519
521
|
assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
|
|
520
522
|
|
|
521
|
-
def test_submit_round_fractional_shares(self, order_proposal, order_factory
|
|
523
|
+
def test_submit_round_fractional_shares(self, order_proposal, order_factory):
|
|
522
524
|
order_proposal.portfolio.only_weighting = False
|
|
523
525
|
order_proposal.portfolio.save()
|
|
524
526
|
trade = order_factory.create(
|
|
525
|
-
underlying_instrument=instrument,
|
|
526
527
|
shares=5.6,
|
|
527
528
|
order_proposal=order_proposal,
|
|
528
529
|
weighting=Decimal("1.0"),
|
|
@@ -640,6 +641,7 @@ class TestOrderProposal:
|
|
|
640
641
|
order_proposal.submit()
|
|
641
642
|
order_proposal.save()
|
|
642
643
|
order_proposal.approve()
|
|
644
|
+
order_proposal.apply()
|
|
643
645
|
order_proposal.save()
|
|
644
646
|
|
|
645
647
|
# Final check that weights have been updated to 50%
|
|
@@ -651,8 +653,9 @@ class TestOrderProposal:
|
|
|
651
653
|
)
|
|
652
654
|
|
|
653
655
|
def test_replay_reset_draft_order_proposal(
|
|
654
|
-
self,
|
|
656
|
+
self, instrument_factory, instrument_price_factory, order_factory, order_proposal_factory
|
|
655
657
|
):
|
|
658
|
+
instrument = instrument_factory.create()
|
|
656
659
|
order_proposal = order_proposal_factory.create(trade_date=date.today() - BDay(2))
|
|
657
660
|
instrument_price_factory.create(instrument=instrument, date=date.today() - BDay(2))
|
|
658
661
|
instrument_price_factory.create(instrument=instrument, date=date.today() - BDay(1))
|
|
@@ -663,7 +666,8 @@ class TestOrderProposal:
|
|
|
663
666
|
weighting=1,
|
|
664
667
|
)
|
|
665
668
|
order_proposal.submit()
|
|
666
|
-
order_proposal.approve(
|
|
669
|
+
order_proposal.approve()
|
|
670
|
+
order_proposal.apply(replay=False)
|
|
667
671
|
order_proposal.save()
|
|
668
672
|
|
|
669
673
|
draft_tp = order_proposal_factory.create(portfolio=order_proposal.portfolio, trade_date=date.today() - BDay(1))
|
|
@@ -700,6 +704,7 @@ class TestOrderProposal:
|
|
|
700
704
|
)
|
|
701
705
|
order_proposal.submit()
|
|
702
706
|
order_proposal.approve()
|
|
707
|
+
order_proposal.apply()
|
|
703
708
|
order_proposal.save()
|
|
704
709
|
|
|
705
710
|
order1.refresh_from_db()
|
|
@@ -733,3 +738,12 @@ class TestOrderProposal:
|
|
|
733
738
|
assert order_proposal.get_orders().aggregate(s=Sum("target_weight"))["s"] == Decimal(
|
|
734
739
|
"0.98"
|
|
735
740
|
), "The total target weight leftover does not equal the stored total cash weight"
|
|
741
|
+
|
|
742
|
+
def test_convert_to_portfolio_always_100percent(self, order_proposal, order_factory):
|
|
743
|
+
o1 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.5"))
|
|
744
|
+
o2 = order_factory.create(order_proposal=order_proposal, weighting=Decimal("0.3"))
|
|
745
|
+
|
|
746
|
+
portfolio = order_proposal.convert_to_portfolio()
|
|
747
|
+
assert portfolio.positions_map[o1.underlying_instrument.id].weighting == Decimal("0.5")
|
|
748
|
+
assert portfolio.positions_map[o2.underlying_instrument.id].weighting == Decimal("0.3")
|
|
749
|
+
assert portfolio.positions_map[order_proposal.cash_component.id].weighting == Decimal("0.2")
|
|
@@ -31,16 +31,15 @@ class TestImportMixinModel:
|
|
|
31
31
|
|
|
32
32
|
trade = trade_factory.build()
|
|
33
33
|
data = {"data": [serialize(trade)]}
|
|
34
|
-
handler = TradeImportHandler(import_source)
|
|
35
34
|
|
|
36
35
|
# Import non existing data
|
|
37
|
-
|
|
36
|
+
TradeImportHandler(import_source).process(data)
|
|
38
37
|
assert Trade.objects.count() == 1
|
|
39
38
|
|
|
40
39
|
# Import already existing data
|
|
41
40
|
# import_source.data['data'][0]['shares'] *= 2
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
TradeImportHandler(import_source).process(data)
|
|
44
43
|
assert Trade.objects.count() == 1
|
|
45
44
|
|
|
46
45
|
def test_import_price(self, import_source, product, instrument_price_factory, instrument):
|
|
@@ -137,7 +136,9 @@ class TestImportMixinModel:
|
|
|
137
136
|
data = {
|
|
138
137
|
"data": [
|
|
139
138
|
self._serialize_position(
|
|
140
|
-
asset_position_factory.build(
|
|
139
|
+
asset_position_factory.build(
|
|
140
|
+
date=val_date, underlying_instrument=instrument, weighting=Decimal("0.25")
|
|
141
|
+
),
|
|
141
142
|
product_portfolio,
|
|
142
143
|
instrument,
|
|
143
144
|
)
|
|
@@ -171,7 +172,7 @@ class TestImportMixinModel:
|
|
|
171
172
|
def test_import_assetposition_product_group(
|
|
172
173
|
self, import_source, product_group, currency, equity, asset_position_factory
|
|
173
174
|
):
|
|
174
|
-
positions = asset_position_factory.build(underlying_instrument=equity)
|
|
175
|
+
positions = asset_position_factory.build(underlying_instrument=equity, weighting=Decimal("1"))
|
|
175
176
|
data = {"data": [self._serialize_position(positions, product_group, equity)]}
|
|
176
177
|
|
|
177
178
|
# Import non existing data
|
|
@@ -183,7 +184,7 @@ class TestImportMixinModel:
|
|
|
183
184
|
def test_import_assetposition_index(
|
|
184
185
|
self, import_source, index, portfolio, currency, equity, asset_position_factory
|
|
185
186
|
):
|
|
186
|
-
positions = asset_position_factory.build(underlying_instrument=equity)
|
|
187
|
+
positions = asset_position_factory.build(underlying_instrument=equity, weighting=Decimal("1"))
|
|
187
188
|
index.portfolios.add(portfolio)
|
|
188
189
|
data = {"data": [self._serialize_position(positions, index, equity)]}
|
|
189
190
|
|