wbportfolio 1.55.9__py2.py3-none-any.whl → 1.56.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/api_clients/ubs.py +11 -9
- wbportfolio/contrib/company_portfolio/configs/display.py +4 -4
- wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
- wbportfolio/contrib/company_portfolio/filters.py +10 -10
- wbportfolio/contrib/company_portfolio/scripts.py +7 -2
- wbportfolio/factories/product_groups.py +3 -3
- wbportfolio/factories/products.py +3 -3
- wbportfolio/filters/assets.py +0 -1
- wbportfolio/filters/positions.py +0 -1
- wbportfolio/filters/transactions/fees.py +0 -2
- wbportfolio/filters/transactions/trades.py +0 -1
- wbportfolio/import_export/handlers/asset_position.py +3 -3
- 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 +2 -2
- 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 +21 -3
- 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/equity.py +2 -1
- wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
- wbportfolio/import_export/utils.py +3 -1
- wbportfolio/metric/backends/base.py +2 -2
- wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
- wbportfolio/models/asset.py +3 -2
- wbportfolio/models/builder.py +0 -1
- wbportfolio/models/custodians.py +3 -3
- wbportfolio/models/exceptions.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 +11 -8
- wbportfolio/models/orders/orders.py +84 -62
- wbportfolio/models/portfolio.py +7 -7
- wbportfolio/models/portfolio_relationship.py +6 -0
- wbportfolio/models/products.py +3 -0
- wbportfolio/models/rebalancing.py +3 -0
- 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 +1 -0
- wbportfolio/models/transactions/transactions.py +16 -4
- wbportfolio/pms/typing.py +1 -1
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +1 -5
- wbportfolio/risk_management/backends/controversy_portfolio.py +1 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +2 -2
- wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -1
- 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/orders.py +56 -23
- wbportfolio/serializers/transactions/claim.py +2 -2
- wbportfolio/tests/models/orders/test_order_proposals.py +27 -7
- wbportfolio/tests/models/test_portfolios.py +5 -5
- wbportfolio/tests/models/test_splits.py +1 -6
- wbportfolio/tests/viewsets/test_products.py +1 -0
- wbportfolio/viewsets/charts/assets.py +8 -4
- wbportfolio/viewsets/configs/buttons/mixins.py +2 -2
- wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
- wbportfolio/viewsets/orders/orders.py +22 -4
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/RECORD +73 -72
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.55.9.dist-info → wbportfolio-1.56.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -194,6 +194,7 @@ 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()
|
|
197
198
|
if abs(self.weighting) < 10e-6:
|
|
198
199
|
self.weighting = Decimal("0")
|
|
199
200
|
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/pms/typing.py
CHANGED
|
@@ -269,7 +269,7 @@ class TradeBatch:
|
|
|
269
269
|
|
|
270
270
|
def convert_to_portfolio(self, use_effective: bool = False, *extra_positions):
|
|
271
271
|
positions = []
|
|
272
|
-
for
|
|
272
|
+
for trade in self.trades_map.values():
|
|
273
273
|
positions.append(
|
|
274
274
|
Position(
|
|
275
275
|
underlying_instrument=trade.underlying_instrument,
|
|
@@ -108,11 +108,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
|
|
|
108
108
|
return df.any()
|
|
109
109
|
else:
|
|
110
110
|
if missing_exchanges.exists():
|
|
111
|
-
|
|
112
|
-
self,
|
|
113
|
-
"_validation_errors",
|
|
114
|
-
f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
|
|
115
|
-
)
|
|
111
|
+
self._validation_errors = f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}"
|
|
116
112
|
return df.all()
|
|
117
113
|
return False
|
|
118
114
|
|
|
@@ -44,7 +44,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
|
|
|
44
44
|
return RuleBackendSerializer
|
|
45
45
|
|
|
46
46
|
def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
|
|
47
|
-
for instrument_id
|
|
47
|
+
for instrument_id in portfolio.positions_map.keys():
|
|
48
48
|
instrument = Instrument.objects.get(id=instrument_id)
|
|
49
49
|
if (
|
|
50
50
|
controversies := Controversy.objects.filter(
|
|
@@ -180,7 +180,7 @@ class RuleBackend(
|
|
|
180
180
|
breached_value = f'<span style="color:green">{breached_value}</span>'
|
|
181
181
|
yield backend.IncidentResult(
|
|
182
182
|
breached_object=obj,
|
|
183
|
-
breached_object_repr=obj_repr,
|
|
183
|
+
breached_object_repr=str(obj_repr),
|
|
184
184
|
breached_value=breached_value,
|
|
185
185
|
report_details=self.report_details,
|
|
186
186
|
severity=severity,
|
|
@@ -218,7 +218,7 @@ class RuleBackend(
|
|
|
218
218
|
obj = Instrument.objects.get(id=pivot_object_id)
|
|
219
219
|
return obj, str(obj)
|
|
220
220
|
case self.GroupbyChoices.ASSET_TYPE:
|
|
221
|
-
return None, InstrumentType.objects.get(id=pivot_object_id)
|
|
221
|
+
return None, InstrumentType.objects.get(id=pivot_object_id).name
|
|
222
222
|
case self.GroupbyChoices.CASH:
|
|
223
223
|
return None, "Cash"
|
|
224
224
|
case self.GroupbyChoices.COUNTRY:
|
|
@@ -64,7 +64,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
|
|
|
64
64
|
return RuleBackendSerializer
|
|
65
65
|
|
|
66
66
|
def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
|
|
67
|
-
for instrument_id
|
|
67
|
+
for instrument_id in portfolio.positions_map.keys():
|
|
68
68
|
instrument = Instrument.objects.get(id=instrument_id)
|
|
69
69
|
relationships = self.instruments_relationship.filter(instrument=instrument, validated=True)
|
|
70
70
|
|
|
@@ -138,5 +138,5 @@ class TestExposurePortfolioRuleModel(PortfolioTestMixin):
|
|
|
138
138
|
incidents = list(exposure_portfolio_backend.check_rule())
|
|
139
139
|
assert len(incidents) == 1
|
|
140
140
|
incident = incidents[0]
|
|
141
|
-
assert incident.breached_object_repr == i1.instrument_type
|
|
141
|
+
assert incident.breached_object_repr == i1.instrument_type.name
|
|
142
142
|
assert incident.breached_value == '<span style="color:green">+5.00%</span>'
|
|
@@ -92,7 +92,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
|
|
|
92
92
|
date=weekday, net_value=500, calculated=False, instrument=benchmark
|
|
93
93
|
)
|
|
94
94
|
RelatedInstrumentThroughModel.objects.create(instrument=product, related_instrument=benchmark, is_primary=True)
|
|
95
|
-
|
|
95
|
+
stop_loss_instrument_backend.dynamic_benchmark_type = "PRIMARY_BENCHMARK"
|
|
96
96
|
|
|
97
97
|
res = list(stop_loss_instrument_backend.check_rule())
|
|
98
98
|
assert len(res) == 0
|
|
@@ -105,7 +105,7 @@ class TestStopLossInstrumentRuleModel(PortfolioTestMixin):
|
|
|
105
105
|
assert len(res) == 1
|
|
106
106
|
assert res[0].breached_object.id == product.id
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
stop_loss_instrument_backend.static_benchmark = benchmark
|
|
109
109
|
res = list(stop_loss_instrument_backend.check_rule())
|
|
110
110
|
assert len(res) == 1
|
|
111
111
|
assert res[0].breached_object.id == product.id
|
|
@@ -119,7 +119,7 @@ class TestStopLossPortfolioRuleModel(PortfolioTestMixin):
|
|
|
119
119
|
res = list(stop_loss_portfolio_backend.check_rule())
|
|
120
120
|
assert len(res) == 0
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
stop_loss_portfolio_backend.static_benchmark = benchmark
|
|
123
123
|
res = list(stop_loss_portfolio_backend.check_rule())
|
|
124
124
|
assert len(res) == 1
|
|
125
125
|
assert res[0].breached_object.id == instrument.id
|
|
@@ -44,29 +44,26 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
44
44
|
underlying_instrument_instrument_type = wb_serializers.CharField(read_only=True)
|
|
45
45
|
underlying_instrument_exchange = wb_serializers.CharField(read_only=True)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
effective_weight = wb_serializers.DecimalField(
|
|
48
|
+
read_only=True,
|
|
48
49
|
max_digits=Order.ORDER_WEIGHTING_PRECISION + 1,
|
|
49
50
|
decimal_places=Order.ORDER_WEIGHTING_PRECISION,
|
|
50
|
-
required=False,
|
|
51
51
|
default=0,
|
|
52
52
|
)
|
|
53
|
-
|
|
54
|
-
read_only=True,
|
|
53
|
+
target_weight = wb_serializers.DecimalField(
|
|
55
54
|
max_digits=Order.ORDER_WEIGHTING_PRECISION + 1,
|
|
56
55
|
decimal_places=Order.ORDER_WEIGHTING_PRECISION,
|
|
57
|
-
|
|
56
|
+
required=False,
|
|
58
57
|
)
|
|
59
58
|
|
|
60
59
|
effective_shares = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=6, default=0)
|
|
61
|
-
target_shares = wb_serializers.DecimalField(
|
|
60
|
+
target_shares = wb_serializers.DecimalField(required=False, max_digits=16, decimal_places=6)
|
|
62
61
|
|
|
63
|
-
total_value_fx_portfolio = wb_serializers.DecimalField(read_only=True, max_digits=16, decimal_places=2, default=0)
|
|
64
62
|
effective_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
65
63
|
read_only=True, max_digits=16, decimal_places=2, default=0
|
|
66
64
|
)
|
|
67
|
-
target_total_value_fx_portfolio = wb_serializers.DecimalField(
|
|
68
|
-
|
|
69
|
-
)
|
|
65
|
+
target_total_value_fx_portfolio = wb_serializers.DecimalField(required=False, max_digits=16, decimal_places=2)
|
|
66
|
+
total_value_fx_portfolio = wb_serializers.DecimalField(required=False, max_digits=16, decimal_places=2)
|
|
70
67
|
|
|
71
68
|
portfolio_currency = wb_serializers.CharField(read_only=True)
|
|
72
69
|
has_warnings = wb_serializers.BooleanField(read_only=True)
|
|
@@ -81,17 +78,57 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
81
78
|
}
|
|
82
79
|
)
|
|
83
80
|
effective_weight = self.instance._effective_weight if self.instance else Decimal(0.0)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
data["
|
|
90
|
-
|
|
91
|
-
data
|
|
92
|
-
|
|
81
|
+
effective_shares = self.instance._effective_shares if self.instance else Decimal(0.0)
|
|
82
|
+
portfolio_value = (
|
|
83
|
+
self.context["view"].order_proposal.portfolio_total_asset_value if "view" in self.context else Decimal(0.0)
|
|
84
|
+
)
|
|
85
|
+
if (total_value_fx_portfolio := data.pop("total_value_fx_portfolio", None)) is not None and portfolio_value:
|
|
86
|
+
data["weighting"] = total_value_fx_portfolio / portfolio_value
|
|
87
|
+
if (
|
|
88
|
+
target_total_value_fx_portfolio := data.pop("target_total_value_fx_portfolio", None)
|
|
89
|
+
) is not None and portfolio_value:
|
|
90
|
+
data["target_weight"] = target_total_value_fx_portfolio / portfolio_value
|
|
91
|
+
|
|
92
|
+
if data.get("weighting") or data.get("target_weight"):
|
|
93
|
+
weighting = data.pop("weighting", None)
|
|
94
|
+
if (target_weight := data.pop("target_weight", None)) is not None:
|
|
95
|
+
weighting = target_weight - effective_weight
|
|
96
|
+
data["desired_target_weight"] = target_weight
|
|
97
|
+
if weighting is not None:
|
|
98
|
+
data["weighting"] = weighting
|
|
99
|
+
data.pop("shares", None)
|
|
100
|
+
data.pop("target_shares", None)
|
|
101
|
+
|
|
102
|
+
if data.get("shares") or data.get("target_shares"):
|
|
103
|
+
shares = data.pop("shares", None)
|
|
104
|
+
if (target_shares := data.pop("target_shares", None)) is not None:
|
|
105
|
+
shares = target_shares - effective_shares
|
|
106
|
+
if shares is not None:
|
|
107
|
+
data["shares"] = shares
|
|
93
108
|
return super().validate(data)
|
|
94
109
|
|
|
110
|
+
def update(self, instance, validated_data):
|
|
111
|
+
weighting = validated_data.pop("weighting", None)
|
|
112
|
+
shares = validated_data.pop("shares", None)
|
|
113
|
+
portfolio_total_asset_value = instance.order_proposal.portfolio_total_asset_value
|
|
114
|
+
if weighting is not None:
|
|
115
|
+
instance.set_weighting(weighting, portfolio_total_asset_value)
|
|
116
|
+
if shares is not None:
|
|
117
|
+
instance.set_shares(shares, portfolio_total_asset_value)
|
|
118
|
+
return super().update(instance, validated_data)
|
|
119
|
+
|
|
120
|
+
def create(self, validated_data):
|
|
121
|
+
weighting = validated_data.pop("weighting", None)
|
|
122
|
+
shares = validated_data.pop("shares", None)
|
|
123
|
+
instance = super().create(validated_data)
|
|
124
|
+
portfolio_total_asset_value = instance.order_proposal.portfolio_total_asset_value
|
|
125
|
+
if weighting is not None:
|
|
126
|
+
instance.set_weighting(weighting, portfolio_total_asset_value)
|
|
127
|
+
if shares is not None:
|
|
128
|
+
instance.set_shares(shares, portfolio_total_asset_value)
|
|
129
|
+
instance.save()
|
|
130
|
+
return instance
|
|
131
|
+
|
|
95
132
|
class Meta:
|
|
96
133
|
model = Order
|
|
97
134
|
percent_fields = ["effective_weight", "target_weight", "weighting"]
|
|
@@ -108,12 +145,8 @@ class OrderOrderProposalListModelSerializer(wb_serializers.ModelSerializer):
|
|
|
108
145
|
}
|
|
109
146
|
read_only_fields = (
|
|
110
147
|
"order_type",
|
|
111
|
-
"shares",
|
|
112
148
|
"effective_shares",
|
|
113
|
-
"target_shares",
|
|
114
|
-
"total_value_fx_portfolio",
|
|
115
149
|
"effective_total_value_fx_portfolio",
|
|
116
|
-
"target_total_value_fx_portfolio",
|
|
117
150
|
"has_warnings",
|
|
118
151
|
)
|
|
119
152
|
fields = (
|
|
@@ -49,8 +49,8 @@ class ClaimAPIModelSerializer(serializers.ModelSerializer):
|
|
|
49
49
|
if "isin" in request.data:
|
|
50
50
|
try:
|
|
51
51
|
data["product"] = Product.objects.get(isin=request.data["isin"])
|
|
52
|
-
except Product.DoesNotExist:
|
|
53
|
-
raise ValidationError({"isin": "A product with this ISIN does not exist."})
|
|
52
|
+
except Product.DoesNotExist as e:
|
|
53
|
+
raise ValidationError({"isin": "A product with this ISIN does not exist."}) from e
|
|
54
54
|
if trade := data.get("trade", None):
|
|
55
55
|
if not trade.is_claimable:
|
|
56
56
|
raise ValidationError({"trade": "Only a claimable trade can be selected"})
|
|
@@ -500,17 +500,25 @@ class TestOrderProposal:
|
|
|
500
500
|
instrument.exchange.save()
|
|
501
501
|
assert order_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
502
502
|
|
|
503
|
-
|
|
503
|
+
@patch.object(Portfolio, "get_total_asset_value")
|
|
504
|
+
def test_submit_round_lot_size(self, mock_fct, order_proposal, instrument_price_factory, order_factory):
|
|
505
|
+
initial_shares = Decimal("70")
|
|
506
|
+
price = instrument_price_factory.create(date=order_proposal.trade_date)
|
|
507
|
+
net_value = round(price.net_value, 4)
|
|
508
|
+
portfolio_value = initial_shares * net_value
|
|
509
|
+
mock_fct.return_value = portfolio_value
|
|
510
|
+
|
|
504
511
|
order_proposal.portfolio.only_weighting = False
|
|
505
512
|
order_proposal.portfolio.save()
|
|
506
|
-
instrument =
|
|
513
|
+
instrument = price.instrument
|
|
507
514
|
instrument.round_lot_size = 100
|
|
508
515
|
instrument.save()
|
|
509
516
|
trade = order_factory.create(
|
|
510
|
-
|
|
511
|
-
shares=70,
|
|
517
|
+
shares=initial_shares,
|
|
512
518
|
order_proposal=order_proposal,
|
|
513
519
|
weighting=Decimal("1.0"),
|
|
520
|
+
underlying_instrument=price.instrument,
|
|
521
|
+
price=net_value,
|
|
514
522
|
)
|
|
515
523
|
warnings = order_proposal.submit()
|
|
516
524
|
order_proposal.save()
|
|
@@ -520,18 +528,31 @@ class TestOrderProposal:
|
|
|
520
528
|
trade.refresh_from_db()
|
|
521
529
|
assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
|
|
522
530
|
|
|
523
|
-
|
|
531
|
+
@patch.object(Portfolio, "get_total_asset_value")
|
|
532
|
+
def test_submit_round_fractional_shares(self, mock_fct, instrument_price_factory, order_proposal, order_factory):
|
|
533
|
+
initial_shares = Decimal("5.6")
|
|
534
|
+
price = instrument_price_factory.create(date=order_proposal.trade_date)
|
|
535
|
+
net_value = round(price.net_value, 4)
|
|
536
|
+
portfolio_value = initial_shares * net_value
|
|
537
|
+
mock_fct.return_value = portfolio_value
|
|
538
|
+
|
|
524
539
|
order_proposal.portfolio.only_weighting = False
|
|
525
540
|
order_proposal.portfolio.save()
|
|
526
541
|
trade = order_factory.create(
|
|
527
|
-
shares=5.6,
|
|
542
|
+
shares=Decimal("5.6"),
|
|
528
543
|
order_proposal=order_proposal,
|
|
529
544
|
weighting=Decimal("1.0"),
|
|
545
|
+
underlying_instrument=price.instrument,
|
|
546
|
+
price=net_value,
|
|
530
547
|
)
|
|
531
548
|
order_proposal.submit()
|
|
532
549
|
order_proposal.save()
|
|
533
550
|
trade.refresh_from_db()
|
|
534
551
|
assert trade.shares == 6 # we expect the fractional share to be rounded
|
|
552
|
+
assert trade.weighting == round((trade.shares * net_value) / portfolio_value, 8)
|
|
553
|
+
assert trade.weighting == round(
|
|
554
|
+
Decimal("1") + ((Decimal("6") - initial_shares) * net_value) / portfolio_value, 8
|
|
555
|
+
) # we expect the weighting to be updated accrodingly
|
|
535
556
|
|
|
536
557
|
def test_ex_post(
|
|
537
558
|
self, instrument_factory, asset_position_factory, instrument_price_factory, order_proposal_factory, portfolio
|
|
@@ -719,7 +740,6 @@ class TestOrderProposal:
|
|
|
719
740
|
order_proposal.approve()
|
|
720
741
|
order_proposal.apply()
|
|
721
742
|
order_proposal.save()
|
|
722
|
-
|
|
723
743
|
assert order_proposal.portfolio.assets.get(
|
|
724
744
|
date=order_proposal.trade_date, underlying_quote=o1.underlying_instrument
|
|
725
745
|
).weighting == Decimal("0.8")
|
|
@@ -924,13 +924,13 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
924
924
|
|
|
925
925
|
analytic_portfolio = portfolio.get_analytic_portfolio(weekday)
|
|
926
926
|
assert analytic_portfolio.weights.tolist() == [float(a1.weighting), float(a2.weighting)]
|
|
927
|
-
|
|
927
|
+
expected_x = pd.DataFrame(
|
|
928
928
|
[[float(p11.net_value / p10.net_value - Decimal(1)), float(p21.net_value / p20.net_value - Decimal(1))]],
|
|
929
929
|
columns=[i1.id, i2.id],
|
|
930
930
|
index=[(weekday + BDay(1)).date()],
|
|
931
931
|
)
|
|
932
|
-
|
|
933
|
-
pd.testing.assert_frame_equal(analytic_portfolio.X,
|
|
932
|
+
expected_x.index = pd.to_datetime(expected_x.index)
|
|
933
|
+
pd.testing.assert_frame_equal(analytic_portfolio.X, expected_x, check_names=False, check_freq=False)
|
|
934
934
|
|
|
935
935
|
def test_get_total_asset_value(self, weekday, portfolio, asset_position_factory):
|
|
936
936
|
a1 = asset_position_factory.create(date=weekday, portfolio=portfolio)
|
|
@@ -1010,7 +1010,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1010
1010
|
assert len(res) == 1
|
|
1011
1011
|
assert a.date == weekday
|
|
1012
1012
|
assert a.underlying_quote == instrument
|
|
1013
|
-
assert a.underlying_quote_price
|
|
1013
|
+
assert a.underlying_quote_price is None
|
|
1014
1014
|
assert a.initial_price == p.net_value
|
|
1015
1015
|
assert a.weighting == pytest.approx(weights[instrument.id], abs=Decimal(10e-6))
|
|
1016
1016
|
assert a.currency_fx_rate_portfolio_to_usd == fx_portfolio
|
|
@@ -1053,7 +1053,7 @@ class TestPortfolioModel(PortfolioTestMixin):
|
|
|
1053
1053
|
assert next(gen)[0] == middle_date, "Drifting weight with a non automatic rebalancer stops the iteration"
|
|
1054
1054
|
try:
|
|
1055
1055
|
next(gen)
|
|
1056
|
-
|
|
1056
|
+
raise AssertionError("the next iteration should stop and return the rebalancing")
|
|
1057
1057
|
except StopIteration as e:
|
|
1058
1058
|
rebalancing_order_proposal = e.value
|
|
1059
1059
|
assert rebalancing_order_proposal.trade_date == rebalancing_date
|
|
@@ -14,10 +14,6 @@ fake = Faker()
|
|
|
14
14
|
|
|
15
15
|
@pytest.mark.django_db
|
|
16
16
|
class TestAdjustmentModel:
|
|
17
|
-
@pytest.fixture()
|
|
18
|
-
def applied_adjustment(self):
|
|
19
|
-
return AdjustmentFactory.create(status=Adjustment.Status.APPLIED)
|
|
20
|
-
|
|
21
17
|
@pytest.fixture()
|
|
22
18
|
def old_adjustment(self):
|
|
23
19
|
return AdjustmentFactory.create(status=Adjustment.Status.PENDING, date=fake.past_date())
|
|
@@ -205,7 +201,7 @@ class TestAdjustmentModel:
|
|
|
205
201
|
post_adjustment_on_prices(adjustment.id)
|
|
206
202
|
a1.refresh_from_db()
|
|
207
203
|
adjustment.refresh_from_db()
|
|
208
|
-
a1.applied_adjustment == adjustment
|
|
204
|
+
assert a1.applied_adjustment == adjustment
|
|
209
205
|
assert adjustment.status == Adjustment.Status.APPLIED
|
|
210
206
|
|
|
211
207
|
@patch("wbportfolio.models.adjustments.send_notification")
|
|
@@ -230,5 +226,4 @@ class TestAdjustmentModel:
|
|
|
230
226
|
mock_check_fct.return_value = False
|
|
231
227
|
post_adjustment_on_prices(adjustment.id)
|
|
232
228
|
adjustment.refresh_from_db()
|
|
233
|
-
mock_delay_fct.call_args[0] == user_porftolio_manager.id
|
|
234
229
|
assert adjustment.status == Adjustment.Status.PENDING
|
|
@@ -23,6 +23,7 @@ class TestProductModelViewSet:
|
|
|
23
23
|
portfolio_factory.create_batch(4, invested_timespan=DateRange(date.min, date.max))
|
|
24
24
|
+ portfolio_factory.create_batch(2, invested_timespan=DateRange(date.min, date.max)),
|
|
25
25
|
product_factory.create_batch(6),
|
|
26
|
+
strict=False,
|
|
26
27
|
):
|
|
27
28
|
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
28
29
|
instrument=product, defaults={"portfolio": portfolio}
|
|
@@ -101,7 +101,9 @@ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
|
|
|
101
101
|
columns = {}
|
|
102
102
|
level_representations = self.classification_group.get_levels_representation()
|
|
103
103
|
for key, label in zip(
|
|
104
|
-
reversed(self.classification_group.get_fields_names(sep="_")),
|
|
104
|
+
reversed(self.classification_group.get_fields_names(sep="_")),
|
|
105
|
+
reversed(level_representations[1:]),
|
|
106
|
+
strict=False,
|
|
105
107
|
):
|
|
106
108
|
columns[key] = label
|
|
107
109
|
columns["label"] = "Classification"
|
|
@@ -158,6 +160,7 @@ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
|
|
|
158
160
|
return df.reset_index(names="id")
|
|
159
161
|
|
|
160
162
|
def manipulate_dataframe(self, df):
|
|
163
|
+
df["id"] = df["id"].fillna(-1)
|
|
161
164
|
if self.group_by == AssetPositionGroupBy.INDUSTRY:
|
|
162
165
|
if not PortfolioRole.is_analyst(self.request.user.profile, portfolio=self.portfolio):
|
|
163
166
|
df["equity"] = ""
|
|
@@ -169,8 +172,8 @@ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
|
|
|
169
172
|
dict(Classification.objects.filter(id__in=df["classification"].dropna()).values_list("id", "name"))
|
|
170
173
|
)
|
|
171
174
|
elif self.group_by == AssetPositionGroupBy.CASH:
|
|
172
|
-
df.loc[df["id"]
|
|
173
|
-
df.loc[df["id"]
|
|
175
|
+
df.loc[df["id"], "label"] = "Cash"
|
|
176
|
+
df.loc[~df["id"], "label"] = "Non-Cash"
|
|
174
177
|
elif self.group_by == AssetPositionGroupBy.COUNTRY:
|
|
175
178
|
df["label"] = df["id"].map(dict(Geography.objects.filter(id__in=df["id"]).values_list("id", "name")))
|
|
176
179
|
elif self.group_by == AssetPositionGroupBy.CURRENCY:
|
|
@@ -180,6 +183,7 @@ class AbstractDistributionMixin(UserPortfolioRequestPermissionMixin):
|
|
|
180
183
|
df["label"] = df["id"].map(
|
|
181
184
|
dict(InstrumentType.objects.filter(id__in=df["id"]).values_list("id", "short_name"))
|
|
182
185
|
)
|
|
186
|
+
df.loc[df["id"] == -1, "label"] = "N/A"
|
|
183
187
|
df.sort_values(by="weighting", ascending=False, inplace=True)
|
|
184
188
|
return df
|
|
185
189
|
|
|
@@ -307,7 +311,7 @@ class ContributorPortfolioChartView(UserPortfolioRequestPermissionMixin, viewset
|
|
|
307
311
|
|
|
308
312
|
text_forex = df_forex.contribution_forex.apply(lambda x: f"{x:,.2%}")
|
|
309
313
|
text_equity = contribution_equity.apply(lambda x: f"{x:,.2%}")
|
|
310
|
-
|
|
314
|
+
self.nb_rows = df.shape[0]
|
|
311
315
|
fig.add_trace(
|
|
312
316
|
go.Bar(
|
|
313
317
|
y=df.instrument_id,
|
|
@@ -7,7 +7,7 @@ from wbfdm.models.instruments import Instrument
|
|
|
7
7
|
|
|
8
8
|
class InstrumentButtonMixin:
|
|
9
9
|
@classmethod
|
|
10
|
-
def add_instrument_request_button(
|
|
10
|
+
def add_instrument_request_button(cls, request=None, view=None, pk=None, **kwargs):
|
|
11
11
|
buttons = [
|
|
12
12
|
bt.WidgetButton(key="assets", label="Implemented Portfolios (Assets)"),
|
|
13
13
|
# bt.WidgetButton(
|
|
@@ -44,7 +44,7 @@ class InstrumentButtonMixin:
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
@classmethod
|
|
47
|
-
def add_transactions_request_button(
|
|
47
|
+
def add_transactions_request_button(cls, request=None, view=None, pk=None, **kwargs):
|
|
48
48
|
return bt.DropDownButton(
|
|
49
49
|
label="Transactions",
|
|
50
50
|
icon=WBIcon.UNFOLD.icon,
|
|
@@ -49,14 +49,14 @@ class AccountReconciliationLineDisplayViewConfig(DisplayViewConfig):
|
|
|
49
49
|
]
|
|
50
50
|
editable = [dp.FormattingRule(style={"fontWeight": "bold"})]
|
|
51
51
|
equal = [dp.FormattingRule(condition=("==", False, "is_equal"), style={"backgroundColor": "orange"})]
|
|
52
|
-
|
|
52
|
+
border_left = [
|
|
53
53
|
dp.FormattingRule(
|
|
54
54
|
style={
|
|
55
55
|
"borderLeft": "1px solid #bdc3c7",
|
|
56
56
|
}
|
|
57
57
|
)
|
|
58
58
|
]
|
|
59
|
-
|
|
59
|
+
border_right = [
|
|
60
60
|
dp.FormattingRule(
|
|
61
61
|
style={
|
|
62
62
|
"borderRight": "1px solid #bdc3c7",
|
|
@@ -111,7 +111,7 @@ class AccountReconciliationLineDisplayViewConfig(DisplayViewConfig):
|
|
|
111
111
|
key="shares_external",
|
|
112
112
|
label="Shares",
|
|
113
113
|
width=90,
|
|
114
|
-
formatting_rules=[*equal, *editable, *
|
|
114
|
+
formatting_rules=[*equal, *editable, *border_left],
|
|
115
115
|
),
|
|
116
116
|
dp.Field(
|
|
117
117
|
key="nominal_value_external",
|
|
@@ -128,7 +128,7 @@ class AccountReconciliationLineDisplayViewConfig(DisplayViewConfig):
|
|
|
128
128
|
key="assets_under_management_external",
|
|
129
129
|
label="AuM",
|
|
130
130
|
width=120,
|
|
131
|
-
formatting_rules=[*equal, *
|
|
131
|
+
formatting_rules=[*equal, *border_right],
|
|
132
132
|
),
|
|
133
133
|
],
|
|
134
134
|
),
|
|
@@ -173,9 +173,27 @@ class OrderOrderProposalModelViewSet(
|
|
|
173
173
|
def get_serializer_class(self):
|
|
174
174
|
if self.order_proposal.status != OrderProposal.Status.DRAFT:
|
|
175
175
|
return ReadOnlyOrderOrderProposalModelSerializer
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
if not self.new_mode and "pk" not in self.kwargs:
|
|
177
|
+
serializer_base_class = OrderOrderProposalListModelSerializer
|
|
178
|
+
else:
|
|
179
|
+
serializer_base_class = OrderOrderProposalModelSerializer
|
|
180
|
+
if not self.order_proposal.portfolio_total_asset_value:
|
|
181
|
+
|
|
182
|
+
class OnlyWeightSerializerClass(serializer_base_class):
|
|
183
|
+
class Meta(serializer_base_class.Meta):
|
|
184
|
+
read_only_fields = (
|
|
185
|
+
"order_type",
|
|
186
|
+
"effective_shares",
|
|
187
|
+
"effective_total_value_fx_portfolio",
|
|
188
|
+
"has_warnings",
|
|
189
|
+
"shares",
|
|
190
|
+
"target_shares",
|
|
191
|
+
"total_value_fx_portfolio",
|
|
192
|
+
"target_total_value_fx_portfolio",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return OnlyWeightSerializerClass
|
|
196
|
+
return serializer_base_class
|
|
179
197
|
|
|
180
198
|
def add_messages(self, request, queryset=None, paginated_queryset=None, instance=None, initial=False):
|
|
181
199
|
if self.orders.exists() and self.order_proposal.status in [
|
|
@@ -214,7 +232,7 @@ class OrderOrderProposalModelViewSet(
|
|
|
214
232
|
default=F("underlying_instrument__instrument_type__short_name"),
|
|
215
233
|
),
|
|
216
234
|
underlying_instrument_exchange=F("underlying_instrument__exchange__name"),
|
|
217
|
-
effective_total_value_fx_portfolio=F("
|
|
235
|
+
effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
|
|
218
236
|
target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
|
|
219
237
|
portfolio_currency=F("portfolio__currency__symbol"),
|
|
220
238
|
security=F("underlying_instrument__parent"),
|