wbportfolio 1.54.13__py2.py3-none-any.whl → 1.54.15__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/__init__.py +2 -0
- wbportfolio/admin/orders/__init__.py +2 -0
- wbportfolio/admin/orders/order_proposals.py +14 -0
- wbportfolio/admin/orders/orders.py +30 -0
- wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
- wbportfolio/admin/transactions/__init__.py +0 -1
- wbportfolio/admin/transactions/trades.py +2 -17
- wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
- wbportfolio/factories/__init__.py +2 -1
- wbportfolio/factories/orders/__init__.py +2 -0
- wbportfolio/factories/orders/order_proposals.py +17 -0
- wbportfolio/factories/orders/orders.py +21 -0
- wbportfolio/factories/rebalancing.py +1 -1
- wbportfolio/factories/trades.py +2 -13
- wbportfolio/filters/orders/__init__.py +1 -0
- wbportfolio/filters/orders/orders.py +11 -0
- wbportfolio/import_export/handlers/trade.py +20 -20
- wbportfolio/import_export/resources/trades.py +2 -2
- wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
- wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
- wbportfolio/models/__init__.py +2 -0
- wbportfolio/models/orders/__init__.py +2 -0
- wbportfolio/models/{transactions/trade_proposals.py → orders/order_proposals.py} +289 -245
- wbportfolio/models/orders/orders.py +243 -0
- wbportfolio/models/portfolio.py +17 -20
- wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +18 -18
- wbportfolio/models/transactions/__init__.py +0 -2
- wbportfolio/models/transactions/trades.py +10 -450
- wbportfolio/pms/analytics/portfolio.py +10 -6
- wbportfolio/pms/analytics/utils.py +9 -0
- wbportfolio/pms/trading/handler.py +6 -4
- wbportfolio/pms/typing.py +18 -7
- wbportfolio/rebalancing/decorators.py +1 -1
- wbportfolio/rebalancing/models/composite.py +3 -7
- wbportfolio/rebalancing/models/market_capitalization_weighted.py +3 -1
- wbportfolio/serializers/__init__.py +1 -0
- wbportfolio/serializers/orders/__init__.py +2 -0
- wbportfolio/serializers/{transactions/trade_proposals.py → orders/order_proposals.py} +23 -15
- wbportfolio/serializers/orders/orders.py +187 -0
- wbportfolio/serializers/portfolios.py +7 -7
- wbportfolio/serializers/rebalancing.py +1 -1
- wbportfolio/serializers/transactions/__init__.py +1 -5
- wbportfolio/serializers/transactions/trades.py +1 -182
- wbportfolio/tests/conftest.py +4 -2
- wbportfolio/tests/models/orders/__init__.py +0 -0
- wbportfolio/tests/models/{transactions/test_trade_proposals.py → orders/test_order_proposals.py} +218 -246
- wbportfolio/tests/models/test_portfolios.py +11 -10
- wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
- wbportfolio/tests/models/transactions/test_trades.py +0 -20
- wbportfolio/tests/rebalancing/test_models.py +24 -28
- wbportfolio/tests/signals.py +10 -10
- wbportfolio/tests/tests.py +1 -1
- wbportfolio/urls.py +7 -7
- wbportfolio/viewsets/__init__.py +2 -0
- wbportfolio/viewsets/configs/buttons/__init__.py +2 -3
- wbportfolio/viewsets/configs/buttons/trades.py +0 -8
- wbportfolio/viewsets/configs/display/__init__.py +0 -2
- wbportfolio/viewsets/configs/display/portfolios.py +5 -5
- wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
- wbportfolio/viewsets/configs/display/trades.py +1 -225
- wbportfolio/viewsets/configs/endpoints/__init__.py +0 -3
- wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
- wbportfolio/viewsets/orders/__init__.py +6 -0
- wbportfolio/viewsets/orders/configs/__init__.py +4 -0
- wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
- wbportfolio/viewsets/{configs/buttons/trade_proposals.py → orders/configs/buttons/order_proposals.py} +22 -21
- wbportfolio/viewsets/orders/configs/buttons/orders.py +9 -0
- wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
- wbportfolio/viewsets/{configs/display/trade_proposals.py → orders/configs/displays/order_proposals.py} +21 -21
- wbportfolio/viewsets/orders/configs/displays/orders.py +180 -0
- wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
- wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
- wbportfolio/viewsets/orders/configs/endpoints/orders.py +26 -0
- wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
- wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
- wbportfolio/viewsets/{transactions/trade_proposals.py → orders/order_proposals.py} +46 -45
- wbportfolio/viewsets/orders/orders.py +219 -0
- wbportfolio/viewsets/portfolios.py +12 -12
- wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
- wbportfolio/viewsets/transactions/__init__.py +1 -7
- wbportfolio/viewsets/transactions/trades.py +1 -199
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/RECORD +85 -58
- wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.13.dist-info → wbportfolio-1.54.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
from
|
|
2
|
-
from datetime import date, timedelta
|
|
1
|
+
from datetime import timedelta
|
|
3
2
|
from decimal import Decimal
|
|
4
3
|
|
|
5
4
|
from celery import shared_task
|
|
6
|
-
from django.contrib import admin
|
|
7
5
|
from django.db import models
|
|
8
6
|
from django.db.models import (
|
|
9
7
|
Case,
|
|
@@ -16,17 +14,10 @@ from django.db.models import (
|
|
|
16
14
|
Sum,
|
|
17
15
|
When,
|
|
18
16
|
)
|
|
19
|
-
from django.db.models.functions import Coalesce
|
|
17
|
+
from django.db.models.functions import Coalesce
|
|
20
18
|
from django.db.models.signals import post_save
|
|
21
19
|
from django.dispatch import receiver
|
|
22
|
-
from django.utils.functional import cached_property
|
|
23
|
-
from django.utils.translation import gettext_lazy as _
|
|
24
|
-
from django_fsm import GET_STATE, FSMField, transition
|
|
25
|
-
from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet
|
|
26
|
-
from wbcore.contrib.icons import WBIcon
|
|
27
20
|
from wbcore.contrib.io.mixins import ImportMixin
|
|
28
|
-
from wbcore.enums import RequestType
|
|
29
|
-
from wbcore.metadata.configs.buttons import ActionButton
|
|
30
21
|
from wbcore.signals import pre_merge
|
|
31
22
|
from wbcore.signals.models import pre_collection
|
|
32
23
|
from wbfdm.models import Instrument
|
|
@@ -34,76 +25,12 @@ from wbfdm.models.instruments.instrument_prices import InstrumentPrice
|
|
|
34
25
|
from wbfdm.signals import add_instrument_to_investable_universe
|
|
35
26
|
|
|
36
27
|
from wbportfolio.import_export.handlers.trade import TradeImportHandler
|
|
37
|
-
from wbportfolio.models.asset import AssetPosition
|
|
38
28
|
from wbportfolio.models.custodians import Custodian
|
|
39
|
-
from wbportfolio.models.roles import PortfolioRole
|
|
40
|
-
from wbportfolio.pms.typing import Trade as TradeDTO
|
|
41
29
|
|
|
42
30
|
from .transactions import TransactionMixin
|
|
43
31
|
|
|
44
32
|
|
|
45
|
-
class
|
|
46
|
-
def annotate_base_info(self):
|
|
47
|
-
return self.annotate(
|
|
48
|
-
last_effective_date=Subquery(
|
|
49
|
-
AssetPosition.unannotated_objects.filter(
|
|
50
|
-
date__lte=OuterRef("value_date"),
|
|
51
|
-
portfolio=OuterRef("portfolio"),
|
|
52
|
-
)
|
|
53
|
-
.order_by("-date")
|
|
54
|
-
.values("date")[:1]
|
|
55
|
-
),
|
|
56
|
-
previous_weight=Coalesce(
|
|
57
|
-
Subquery(
|
|
58
|
-
AssetPosition.unannotated_objects.filter(
|
|
59
|
-
underlying_quote=OuterRef("underlying_instrument"),
|
|
60
|
-
date=OuterRef("last_effective_date"),
|
|
61
|
-
portfolio=OuterRef("portfolio"),
|
|
62
|
-
)
|
|
63
|
-
.values("portfolio")
|
|
64
|
-
.annotate(s=Sum("weighting"))
|
|
65
|
-
.values("s")[:1]
|
|
66
|
-
),
|
|
67
|
-
Decimal(0),
|
|
68
|
-
),
|
|
69
|
-
effective_weight=Round(
|
|
70
|
-
F("previous_weight") * F("drift_factor"), precision=Trade.TRADE_WEIGHTING_PRECISION
|
|
71
|
-
),
|
|
72
|
-
target_weight=Round(F("effective_weight") + F("weighting"), precision=Trade.TRADE_WEIGHTING_PRECISION),
|
|
73
|
-
effective_shares=Coalesce(
|
|
74
|
-
Subquery(
|
|
75
|
-
AssetPosition.objects.filter(
|
|
76
|
-
underlying_quote=OuterRef("underlying_instrument"),
|
|
77
|
-
date=OuterRef("last_effective_date"),
|
|
78
|
-
portfolio=OuterRef("portfolio"),
|
|
79
|
-
)
|
|
80
|
-
.values("portfolio")
|
|
81
|
-
.annotate(s=Sum("shares"))
|
|
82
|
-
.values("s")[:1]
|
|
83
|
-
),
|
|
84
|
-
Decimal(0),
|
|
85
|
-
),
|
|
86
|
-
target_shares=F("effective_shares") + F("shares"),
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class DefaultTradeManager(OrderedModelManager):
|
|
91
|
-
"""This manager is expect to be the trade default manager and annotate by default the effective weight (extracted
|
|
92
|
-
from the associated portfolio) and the target weight as an addition between the effective weight and the delta weight
|
|
93
|
-
"""
|
|
94
|
-
|
|
95
|
-
def __init__(self, with_annotation: bool = False, *args, **kwargs):
|
|
96
|
-
self.with_annotation = with_annotation
|
|
97
|
-
super().__init__(*args, **kwargs)
|
|
98
|
-
|
|
99
|
-
def get_queryset(self) -> TradeQueryset:
|
|
100
|
-
qs = TradeQueryset(self.model, using=self._db)
|
|
101
|
-
if self.with_annotation:
|
|
102
|
-
qs = qs.annotate_base_info()
|
|
103
|
-
return qs
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class ValidCustomerTradeManager(DefaultTradeManager):
|
|
33
|
+
class ValidCustomerTradeManager(models.Manager):
|
|
107
34
|
def __init__(self, without_internal_trade: bool = False):
|
|
108
35
|
self.without_internal_trade = without_internal_trade
|
|
109
36
|
super().__init__()
|
|
@@ -123,20 +50,10 @@ class ValidCustomerTradeManager(DefaultTradeManager):
|
|
|
123
50
|
return qs
|
|
124
51
|
|
|
125
52
|
|
|
126
|
-
class Trade(TransactionMixin, ImportMixin,
|
|
53
|
+
class Trade(TransactionMixin, ImportMixin, models.Model):
|
|
127
54
|
import_export_handler_class = TradeImportHandler
|
|
128
55
|
|
|
129
56
|
TRADE_WINDOW_INTERVAL = 7
|
|
130
|
-
TRADE_WEIGHTING_PRECISION = (
|
|
131
|
-
8 # we need to match the assetposition weighting. Skfolio advices using a even smaller number (5)
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
class Status(models.TextChoices):
|
|
135
|
-
DRAFT = "DRAFT", "Draft"
|
|
136
|
-
SUBMIT = "SUBMIT", "Submit"
|
|
137
|
-
EXECUTED = "EXECUTED", "Executed"
|
|
138
|
-
CONFIRMED = "CONFIRMED", "Confirmed"
|
|
139
|
-
FAILED = "FAILED", "Failed"
|
|
140
57
|
|
|
141
58
|
class Type(models.TextChoices):
|
|
142
59
|
REBALANCE = "REBALANCE", "Rebalance"
|
|
@@ -151,7 +68,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
151
68
|
transaction_subtype = models.CharField(
|
|
152
69
|
max_length=32, default=Type.BUY, choices=Type.choices, verbose_name="Trade Type"
|
|
153
70
|
)
|
|
154
|
-
status = FSMField(default=Status.CONFIRMED, choices=Status.choices, verbose_name="Status")
|
|
155
71
|
transaction_date = models.DateField(
|
|
156
72
|
verbose_name="Trade Date",
|
|
157
73
|
help_text="The date that this transaction was traded.",
|
|
@@ -170,7 +86,7 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
170
86
|
|
|
171
87
|
weighting = models.DecimalField(
|
|
172
88
|
max_digits=9,
|
|
173
|
-
decimal_places=
|
|
89
|
+
decimal_places=8,
|
|
174
90
|
default=Decimal(0),
|
|
175
91
|
help_text="The weight to be multiplied against the target",
|
|
176
92
|
verbose_name="Weight",
|
|
@@ -221,23 +137,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
221
137
|
related_name="trades",
|
|
222
138
|
on_delete=models.PROTECT,
|
|
223
139
|
)
|
|
224
|
-
trade_proposal = models.ForeignKey(
|
|
225
|
-
to="wbportfolio.TradeProposal",
|
|
226
|
-
null=True,
|
|
227
|
-
blank=True,
|
|
228
|
-
related_name="trades",
|
|
229
|
-
on_delete=models.CASCADE,
|
|
230
|
-
help_text="The Trade Proposal this trade is coming from",
|
|
231
|
-
)
|
|
232
|
-
drift_factor = models.DecimalField(
|
|
233
|
-
max_digits=TRADE_WEIGHTING_PRECISION * 2
|
|
234
|
-
+ 3, # we don't expect any drift factor to be in the order of magnitude greater than 1000
|
|
235
|
-
decimal_places=TRADE_WEIGHTING_PRECISION
|
|
236
|
-
* 2, # we need a higher precision for this factor to avoid float inprecision
|
|
237
|
-
default=Decimal(1.0),
|
|
238
|
-
verbose_name="Drift Factor",
|
|
239
|
-
help_text="Drift factor to be applied to the previous portfolio weight to get the actual effective weight including daily return",
|
|
240
|
-
)
|
|
241
140
|
external_id = models.CharField(
|
|
242
141
|
max_length=255,
|
|
243
142
|
null=True,
|
|
@@ -254,211 +153,10 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
254
153
|
)
|
|
255
154
|
|
|
256
155
|
# Manager
|
|
257
|
-
objects =
|
|
258
|
-
annotated_objects = DefaultTradeManager(with_annotation=True)
|
|
156
|
+
objects = models.Manager()
|
|
259
157
|
valid_customer_trade_objects = ValidCustomerTradeManager()
|
|
260
158
|
valid_external_customer_trade_objects = ValidCustomerTradeManager(without_internal_trade=True)
|
|
261
159
|
|
|
262
|
-
@transition(
|
|
263
|
-
field=status,
|
|
264
|
-
source=Status.DRAFT,
|
|
265
|
-
target=GET_STATE(
|
|
266
|
-
lambda self, **kwargs: (self.Status.SUBMIT if self.price else self.Status.FAILED),
|
|
267
|
-
states=[Status.SUBMIT, Status.FAILED],
|
|
268
|
-
),
|
|
269
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
270
|
-
user.profile, portfolio=instance.portfolio
|
|
271
|
-
),
|
|
272
|
-
custom={
|
|
273
|
-
"_transition_button": ActionButton(
|
|
274
|
-
method=RequestType.PATCH,
|
|
275
|
-
identifiers=("wbportfolio:trade",),
|
|
276
|
-
icon=WBIcon.SEND.icon,
|
|
277
|
-
key="submit",
|
|
278
|
-
label="Submit",
|
|
279
|
-
action_label="Submit",
|
|
280
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
281
|
-
)
|
|
282
|
-
},
|
|
283
|
-
on_error="FAILED",
|
|
284
|
-
)
|
|
285
|
-
def submit(self, by=None, description=None, portfolio_total_asset_value=None, **kwargs):
|
|
286
|
-
warnings = []
|
|
287
|
-
# if shares is defined and the underlying instrument defines a round lot size different than 1 and exchange allows its application, we round the share accordingly
|
|
288
|
-
if self.trade_proposal and not self.portfolio.only_weighting:
|
|
289
|
-
shares = self.trade_proposal.get_round_lot_size(self.shares, self.underlying_instrument)
|
|
290
|
-
if shares != self.shares:
|
|
291
|
-
warnings.append(
|
|
292
|
-
f"{self.underlying_instrument.computed_str} has a round lot size of {self.underlying_instrument.round_lot_size}: shares were rounded from {self.shares} to {shares}"
|
|
293
|
-
)
|
|
294
|
-
shares = round(shares) # ensure fractional shares are converted into integer
|
|
295
|
-
# we need to recompute the delta weight has we changed the number of shares
|
|
296
|
-
if shares != self.shares:
|
|
297
|
-
self.shares = shares
|
|
298
|
-
if portfolio_total_asset_value:
|
|
299
|
-
self.weighting = self.shares * self.price * self.currency_fx_rate / portfolio_total_asset_value
|
|
300
|
-
|
|
301
|
-
if not self.price:
|
|
302
|
-
warnings.append(
|
|
303
|
-
f"Trade failed because no price is found for {self.underlying_instrument.computed_str} on {self.transaction_date:%Y-%m-%d}"
|
|
304
|
-
)
|
|
305
|
-
return warnings
|
|
306
|
-
|
|
307
|
-
def can_submit(self):
|
|
308
|
-
pass
|
|
309
|
-
|
|
310
|
-
@transition(
|
|
311
|
-
field=status,
|
|
312
|
-
source=Status.DRAFT,
|
|
313
|
-
target=Status.FAILED,
|
|
314
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
315
|
-
user.profile, portfolio=instance.portfolio
|
|
316
|
-
),
|
|
317
|
-
)
|
|
318
|
-
def fail(self, **kwargs):
|
|
319
|
-
pass
|
|
320
|
-
|
|
321
|
-
# TODO To be removed
|
|
322
|
-
@cached_property
|
|
323
|
-
def last_underlying_quote_price(self) -> InstrumentPrice | None:
|
|
324
|
-
try:
|
|
325
|
-
# we try t0 first
|
|
326
|
-
return InstrumentPrice.objects.filter_only_valid_prices().get(
|
|
327
|
-
instrument=self.underlying_instrument, date=self.transaction_date
|
|
328
|
-
)
|
|
329
|
-
except InstrumentPrice.DoesNotExist:
|
|
330
|
-
with suppress(InstrumentPrice.DoesNotExist):
|
|
331
|
-
# we fall back to the latest price before t0
|
|
332
|
-
return (
|
|
333
|
-
InstrumentPrice.objects.filter_only_valid_prices()
|
|
334
|
-
.filter(instrument=self.underlying_instrument, date__lte=self.transaction_date)
|
|
335
|
-
.latest("date")
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
@transition(
|
|
339
|
-
field=status,
|
|
340
|
-
source=Status.SUBMIT,
|
|
341
|
-
target=Status.EXECUTED,
|
|
342
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
343
|
-
user.profile, portfolio=instance.portfolio
|
|
344
|
-
),
|
|
345
|
-
custom={
|
|
346
|
-
"_transition_button": ActionButton(
|
|
347
|
-
method=RequestType.PATCH,
|
|
348
|
-
identifiers=("wbportfolio:trade",),
|
|
349
|
-
icon=WBIcon.CONFIRM.icon,
|
|
350
|
-
key="execute",
|
|
351
|
-
label="Execute",
|
|
352
|
-
action_label="Execute",
|
|
353
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
354
|
-
)
|
|
355
|
-
},
|
|
356
|
-
)
|
|
357
|
-
def execute(self, **kwargs):
|
|
358
|
-
with suppress(ValueError):
|
|
359
|
-
asset = self.get_asset()
|
|
360
|
-
AssetPosition.unannotated_objects.update_or_create(
|
|
361
|
-
underlying_quote=asset.underlying_quote,
|
|
362
|
-
portfolio_created=asset.portfolio_created,
|
|
363
|
-
portfolio=asset.portfolio,
|
|
364
|
-
date=asset.date,
|
|
365
|
-
defaults={
|
|
366
|
-
"initial_currency_fx_rate": asset.initial_currency_fx_rate,
|
|
367
|
-
"initial_price": asset.initial_price,
|
|
368
|
-
"initial_shares": asset.initial_shares,
|
|
369
|
-
"underlying_quote_price": asset.underlying_quote_price,
|
|
370
|
-
"asset_valuation_date": asset.asset_valuation_date,
|
|
371
|
-
"currency": asset.currency,
|
|
372
|
-
"is_estimated": asset.is_estimated,
|
|
373
|
-
"weighting": asset.weighting,
|
|
374
|
-
},
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
def can_execute(self):
|
|
378
|
-
if not self.price:
|
|
379
|
-
return {"underlying_instrument": [_("Cannot execute a trade without a valid quote price")]}
|
|
380
|
-
if not self.portfolio.is_manageable:
|
|
381
|
-
return {
|
|
382
|
-
"portfolio": [_("The portfolio needs to be a model portfolio in order to execute this trade manually")]
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
@transition(
|
|
386
|
-
field=status,
|
|
387
|
-
source=Status.EXECUTED,
|
|
388
|
-
target=Status.CONFIRMED,
|
|
389
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
390
|
-
user.profile, portfolio=instance.portfolio
|
|
391
|
-
),
|
|
392
|
-
custom={
|
|
393
|
-
"_transition_button": ActionButton(
|
|
394
|
-
method=RequestType.PATCH,
|
|
395
|
-
identifiers=("wbportfolio:trade",),
|
|
396
|
-
icon=WBIcon.CONFIRM.icon,
|
|
397
|
-
key="confirm",
|
|
398
|
-
label="Confirm",
|
|
399
|
-
action_label="Confirme",
|
|
400
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
401
|
-
)
|
|
402
|
-
},
|
|
403
|
-
)
|
|
404
|
-
def confirm(self, by=None, description=None, **kwargs):
|
|
405
|
-
pass
|
|
406
|
-
|
|
407
|
-
def can_confirm(self):
|
|
408
|
-
pass
|
|
409
|
-
|
|
410
|
-
@transition(
|
|
411
|
-
field=status,
|
|
412
|
-
source=Status.SUBMIT,
|
|
413
|
-
target=Status.DRAFT,
|
|
414
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
415
|
-
user.profile, portfolio=instance.portfolio
|
|
416
|
-
),
|
|
417
|
-
custom={
|
|
418
|
-
"_transition_button": ActionButton(
|
|
419
|
-
method=RequestType.PATCH,
|
|
420
|
-
identifiers=("wbportfolio:trade",),
|
|
421
|
-
icon=WBIcon.UNDO.icon,
|
|
422
|
-
key="backtodraft",
|
|
423
|
-
label="Back to Draft",
|
|
424
|
-
action_label="backtodraft",
|
|
425
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
426
|
-
)
|
|
427
|
-
},
|
|
428
|
-
)
|
|
429
|
-
def backtodraft(self, **kwargs):
|
|
430
|
-
pass
|
|
431
|
-
|
|
432
|
-
@transition(
|
|
433
|
-
field=status,
|
|
434
|
-
source=Status.EXECUTED,
|
|
435
|
-
target=Status.DRAFT,
|
|
436
|
-
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
437
|
-
user.profile, portfolio=instance.portfolio
|
|
438
|
-
),
|
|
439
|
-
custom={
|
|
440
|
-
"_transition_button": ActionButton(
|
|
441
|
-
method=RequestType.PATCH,
|
|
442
|
-
identifiers=("wbportfolio:trade",),
|
|
443
|
-
icon=WBIcon.UNDO.icon,
|
|
444
|
-
key="revert",
|
|
445
|
-
label="Revert",
|
|
446
|
-
action_label="revert",
|
|
447
|
-
# description_fields="<p>Start: {{start}}</p><p>End: {{end}}</p><p>Title: {{title}}</p>",
|
|
448
|
-
)
|
|
449
|
-
},
|
|
450
|
-
)
|
|
451
|
-
def revert(self, to_date=None, **kwargs):
|
|
452
|
-
with suppress(AssetPosition.DoesNotExist):
|
|
453
|
-
asset = AssetPosition.unannotated_objects.get(
|
|
454
|
-
underlying_quote=self.underlying_instrument,
|
|
455
|
-
portfolio=self.portfolio,
|
|
456
|
-
date=self.transaction_date,
|
|
457
|
-
is_estimated=False,
|
|
458
|
-
)
|
|
459
|
-
asset.set_weighting(asset.weighting - self.weighting)
|
|
460
|
-
asset.save()
|
|
461
|
-
|
|
462
160
|
@property
|
|
463
161
|
def product(self):
|
|
464
162
|
from wbportfolio.models.products import Product
|
|
@@ -468,77 +166,12 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
468
166
|
except Product.DoesNotExist:
|
|
469
167
|
return None
|
|
470
168
|
|
|
471
|
-
|
|
472
|
-
@admin.display(description="Last Effective Date")
|
|
473
|
-
def _last_effective_date(self) -> date:
|
|
474
|
-
if hasattr(self, "last_effective_date"):
|
|
475
|
-
return self.last_effective_date
|
|
476
|
-
elif (
|
|
477
|
-
assets := AssetPosition.unannotated_objects.filter(
|
|
478
|
-
date__lte=self.value_date,
|
|
479
|
-
portfolio=self.portfolio,
|
|
480
|
-
)
|
|
481
|
-
).exists():
|
|
482
|
-
return assets.latest("date").date
|
|
483
|
-
|
|
484
|
-
@property
|
|
485
|
-
@admin.display(description="Effective Weight")
|
|
486
|
-
def _previous_weight(self) -> Decimal:
|
|
487
|
-
if hasattr(self, "previous_weight"):
|
|
488
|
-
return self.previous_weight
|
|
489
|
-
return AssetPosition.unannotated_objects.filter(
|
|
490
|
-
underlying_quote=self.underlying_instrument,
|
|
491
|
-
date=self._last_effective_date,
|
|
492
|
-
portfolio=self.portfolio,
|
|
493
|
-
).aggregate(s=Sum("weighting"))["s"] or Decimal(0)
|
|
494
|
-
|
|
495
|
-
@property
|
|
496
|
-
@admin.display(description="Effective Weight")
|
|
497
|
-
def _effective_weight(self) -> Decimal:
|
|
498
|
-
if hasattr(self, "effective_weight"):
|
|
499
|
-
return self.effective_weight
|
|
500
|
-
return round(self._previous_weight * self.drift_factor, self.TRADE_WEIGHTING_PRECISION)
|
|
501
|
-
|
|
502
|
-
@property
|
|
503
|
-
@admin.display(description="Effective Shares")
|
|
504
|
-
def _effective_shares(self) -> Decimal:
|
|
505
|
-
return getattr(
|
|
506
|
-
self,
|
|
507
|
-
"effective_shares",
|
|
508
|
-
AssetPosition.objects.filter(
|
|
509
|
-
underlying_quote=self.underlying_instrument,
|
|
510
|
-
date=self.transaction_date,
|
|
511
|
-
portfolio=self.portfolio,
|
|
512
|
-
).aggregate(s=Sum("shares"))["s"]
|
|
513
|
-
or Decimal(0),
|
|
514
|
-
)
|
|
515
|
-
|
|
516
|
-
@property
|
|
517
|
-
@admin.display(description="Target Weight")
|
|
518
|
-
def _target_weight(self) -> Decimal:
|
|
519
|
-
return getattr(
|
|
520
|
-
self, "target_weight", round(self._effective_weight + self.weighting, self.TRADE_WEIGHTING_PRECISION)
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
@_target_weight.setter
|
|
524
|
-
def _target_weight(self, target_weight):
|
|
525
|
-
self.weighting = Decimal(target_weight) - self._effective_weight
|
|
526
|
-
self._set_type()
|
|
527
|
-
|
|
528
|
-
@property
|
|
529
|
-
@admin.display(description="Target Shares")
|
|
530
|
-
def _target_shares(self) -> Decimal:
|
|
531
|
-
return getattr(self, "target_shares", self._effective_shares + self.shares)
|
|
532
|
-
|
|
533
|
-
order_with_respect_to = "trade_proposal"
|
|
534
|
-
|
|
535
|
-
class Meta(OrderedModel.Meta):
|
|
169
|
+
class Meta:
|
|
536
170
|
verbose_name = "Trade"
|
|
537
171
|
verbose_name_plural = "Trades"
|
|
538
172
|
indexes = [
|
|
539
173
|
models.Index(fields=["underlying_instrument", "transaction_date"]),
|
|
540
174
|
models.Index(fields=["portfolio", "underlying_instrument", "transaction_date"]),
|
|
541
|
-
# models.Index(fields=["date", "underlying_instrument"]),
|
|
542
175
|
]
|
|
543
176
|
constraints = [
|
|
544
177
|
models.CheckConstraint(
|
|
@@ -557,38 +190,20 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
557
190
|
),
|
|
558
191
|
name="internal_trade_set_only_for_subred",
|
|
559
192
|
),
|
|
560
|
-
models.UniqueConstraint(
|
|
561
|
-
fields=["portfolio", "transaction_date", "underlying_instrument"],
|
|
562
|
-
name="unique_manual_trade",
|
|
563
|
-
condition=Q(trade_proposal__isnull=False),
|
|
564
|
-
),
|
|
565
193
|
]
|
|
566
194
|
# notification_email_template = "portfolio/email/trade_notification.html"
|
|
567
195
|
|
|
568
196
|
def save(self, *args, **kwargs):
|
|
569
197
|
if abs(self.weighting) < 10e-6:
|
|
570
198
|
self.weighting = Decimal("0")
|
|
571
|
-
if self.trade_proposal:
|
|
572
|
-
if not self.underlying_instrument.is_investable_universe:
|
|
573
|
-
self.underlying_instrument.is_investable_universe = True
|
|
574
|
-
self.underlying_instrument.save()
|
|
575
|
-
self.portfolio = self.trade_proposal.portfolio
|
|
576
|
-
self.transaction_date = self.trade_proposal.trade_date
|
|
577
|
-
self.value_date = self.trade_proposal.last_effective_date
|
|
578
199
|
if not self.price:
|
|
579
200
|
# we try to get the price if not provided directly from the underlying instrument
|
|
580
201
|
self.price = self.get_price()
|
|
581
|
-
if self.trade_proposal and not self.portfolio.only_weighting:
|
|
582
|
-
estimated_shares = self.trade_proposal.get_estimated_shares(
|
|
583
|
-
self.weighting, self.underlying_instrument, self.price
|
|
584
|
-
)
|
|
585
|
-
if estimated_shares:
|
|
586
|
-
self.shares = estimated_shares
|
|
587
202
|
|
|
588
203
|
if not self.custodian and self.bank:
|
|
589
204
|
self.custodian = Custodian.get_by_mapping(self.bank)
|
|
590
205
|
|
|
591
|
-
if self.transaction_subtype is None
|
|
206
|
+
if self.transaction_subtype is None:
|
|
592
207
|
# if subtype not provided, we extract it automatically from the existing data.
|
|
593
208
|
self._set_type()
|
|
594
209
|
if self.id and hasattr(self, "claims"):
|
|
@@ -612,42 +227,12 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
612
227
|
self.transaction_subtype = Trade.Type.REDEMPTION
|
|
613
228
|
elif self.weighting is not None:
|
|
614
229
|
if self.weighting > 0:
|
|
615
|
-
|
|
616
|
-
self.transaction_subtype = Trade.Type.INCREASE
|
|
617
|
-
else:
|
|
618
|
-
self.transaction_subtype = Trade.Type.BUY
|
|
230
|
+
self.transaction_subtype = Trade.Type.INCREASE
|
|
619
231
|
elif self.weighting < 0:
|
|
620
|
-
|
|
621
|
-
self.transaction_subtype = Trade.Type.DECREASE
|
|
622
|
-
else:
|
|
623
|
-
self.transaction_subtype = Trade.Type.SELL
|
|
232
|
+
self.transaction_subtype = Trade.Type.DECREASE
|
|
624
233
|
else:
|
|
625
234
|
self.transaction_subtype = Trade.Type.REBALANCE
|
|
626
235
|
|
|
627
|
-
def get_type(self) -> str:
|
|
628
|
-
"""
|
|
629
|
-
Return the expected transaction subtype based n
|
|
630
|
-
|
|
631
|
-
"""
|
|
632
|
-
|
|
633
|
-
def get_asset(self) -> AssetPosition:
|
|
634
|
-
asset = AssetPosition(
|
|
635
|
-
underlying_quote=self.underlying_instrument,
|
|
636
|
-
portfolio_created=None,
|
|
637
|
-
portfolio=self.portfolio,
|
|
638
|
-
date=self.transaction_date,
|
|
639
|
-
initial_currency_fx_rate=self.currency_fx_rate,
|
|
640
|
-
weighting=self._target_weight,
|
|
641
|
-
initial_price=self.price,
|
|
642
|
-
initial_shares=None,
|
|
643
|
-
asset_valuation_date=self.transaction_date,
|
|
644
|
-
currency=self.currency,
|
|
645
|
-
is_estimated=False,
|
|
646
|
-
)
|
|
647
|
-
asset.set_weighting(self._target_weight)
|
|
648
|
-
asset.pre_save()
|
|
649
|
-
return asset
|
|
650
|
-
|
|
651
236
|
def get_price(self) -> Decimal:
|
|
652
237
|
try:
|
|
653
238
|
return self.underlying_instrument.get_price(self.transaction_date)
|
|
@@ -662,31 +247,6 @@ class Trade(TransactionMixin, ImportMixin, OrderedModel, models.Model):
|
|
|
662
247
|
ticker = f"{self.underlying_instrument.ticker}:" if self.underlying_instrument.ticker else ""
|
|
663
248
|
return f"{ticker}{self.shares} ({self.bank})"
|
|
664
249
|
|
|
665
|
-
def _build_dto(self, drift_factor: Decimal = None) -> TradeDTO:
|
|
666
|
-
"""
|
|
667
|
-
Data Transfer Object
|
|
668
|
-
Returns:
|
|
669
|
-
DTO trade object
|
|
670
|
-
|
|
671
|
-
"""
|
|
672
|
-
if not drift_factor:
|
|
673
|
-
drift_factor = self.drift_factor
|
|
674
|
-
return TradeDTO(
|
|
675
|
-
id=self.id,
|
|
676
|
-
underlying_instrument=self.underlying_instrument.id,
|
|
677
|
-
previous_weight=self._previous_weight,
|
|
678
|
-
target_weight=self._previous_weight * drift_factor + self.weighting,
|
|
679
|
-
effective_shares=self._effective_shares,
|
|
680
|
-
target_shares=self._target_shares,
|
|
681
|
-
drift_factor=drift_factor,
|
|
682
|
-
currency_fx_rate=self.currency_fx_rate,
|
|
683
|
-
price=self.price,
|
|
684
|
-
instrument_type=self.underlying_instrument.security_instrument_type.id,
|
|
685
|
-
currency=self.underlying_instrument.currency,
|
|
686
|
-
date=self.transaction_date,
|
|
687
|
-
is_cash=self.underlying_instrument.is_cash or self.underlying_instrument.is_cash_equivalent,
|
|
688
|
-
)
|
|
689
|
-
|
|
690
250
|
def get_alternative_valid_trades(self, share_delta: float = 0):
|
|
691
251
|
return Trade.objects.filter(
|
|
692
252
|
Q(underlying_instrument=self.underlying_instrument)
|
|
@@ -2,6 +2,8 @@ import numpy as np
|
|
|
2
2
|
import pandas as pd
|
|
3
3
|
from skfolio import Portfolio as BasePortfolio
|
|
4
4
|
|
|
5
|
+
from .utils import fix_quantization_error
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
class Portfolio(BasePortfolio):
|
|
7
9
|
@property
|
|
@@ -16,6 +18,12 @@ class Portfolio(BasePortfolio):
|
|
|
16
18
|
)
|
|
17
19
|
return df
|
|
18
20
|
|
|
21
|
+
def get_contributions(self) -> tuple[pd.Series, float]:
|
|
22
|
+
returns = self.X.iloc[-1, :].T
|
|
23
|
+
weights = self.all_weights_per_observation.iloc[-1, :].T
|
|
24
|
+
portfolio_returns = (weights * (returns + 1.0)).sum()
|
|
25
|
+
return returns, portfolio_returns
|
|
26
|
+
|
|
19
27
|
def get_next_weights(self, round_precision: int = 8) -> dict[int, float]:
|
|
20
28
|
"""
|
|
21
29
|
Given the next returns, compute the drifted weights of this portfolio
|
|
@@ -23,17 +31,13 @@ class Portfolio(BasePortfolio):
|
|
|
23
31
|
Returns:
|
|
24
32
|
A dictionary of weights (instrument ids as keys and weights as values)
|
|
25
33
|
"""
|
|
26
|
-
returns = self.
|
|
34
|
+
returns, portfolio_returns = self.get_contributions()
|
|
27
35
|
weights = self.all_weights_per_observation.iloc[-1, :].T
|
|
28
|
-
portfolio_returns = (weights * (returns + 1.0)).sum()
|
|
29
36
|
next_weights = weights * (returns + 1.0) / portfolio_returns
|
|
30
37
|
next_weights = next_weights.dropna()
|
|
31
38
|
next_weights = next_weights / next_weights.sum()
|
|
32
39
|
if round_precision and not next_weights.empty:
|
|
33
|
-
next_weights = next_weights
|
|
34
|
-
quantization_error = 1.0 - next_weights.sum()
|
|
35
|
-
largest_weight = next_weights.idxmax()
|
|
36
|
-
next_weights.loc[largest_weight] = next_weights.loc[largest_weight] + quantization_error
|
|
40
|
+
next_weights = fix_quantization_error(next_weights, round_precision)
|
|
37
41
|
return {i: round(w, round_precision) for i, w in next_weights.items()} # handle float precision manually
|
|
38
42
|
|
|
39
43
|
def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fix_quantization_error(df: pd.Series, round_precision: int):
|
|
5
|
+
df = df.round(round_precision)
|
|
6
|
+
quantization_error = 1.0 - df.sum()
|
|
7
|
+
largest_weight = df.idxmax()
|
|
8
|
+
df.loc[largest_weight] = df.loc[largest_weight] + quantization_error
|
|
9
|
+
return df
|
|
@@ -117,7 +117,7 @@ class TradingService:
|
|
|
117
117
|
if self._effective_portfolio:
|
|
118
118
|
for trade in validated_trades:
|
|
119
119
|
if (
|
|
120
|
-
trade.
|
|
120
|
+
trade.previous_weight
|
|
121
121
|
and trade.underlying_instrument not in self._effective_portfolio.positions_map
|
|
122
122
|
):
|
|
123
123
|
raise ValidationError("All effective position needs to be matched with a validated trade")
|
|
@@ -137,6 +137,7 @@ class TradingService:
|
|
|
137
137
|
|
|
138
138
|
Returns: The normalized trades batch
|
|
139
139
|
"""
|
|
140
|
+
|
|
140
141
|
instruments = effective_portfolio.positions_map.copy()
|
|
141
142
|
instruments.update(target_portfolio.positions_map)
|
|
142
143
|
|
|
@@ -145,11 +146,11 @@ class TradingService:
|
|
|
145
146
|
if not pos.is_cash:
|
|
146
147
|
previous_weight = target_weight = 0
|
|
147
148
|
effective_shares = target_shares = 0
|
|
148
|
-
|
|
149
|
+
daily_return = 0
|
|
149
150
|
if effective_pos := effective_portfolio.positions_map.get(instrument_id, None):
|
|
150
151
|
previous_weight = effective_pos.weighting
|
|
151
152
|
effective_shares = effective_pos.shares
|
|
152
|
-
|
|
153
|
+
daily_return = effective_pos.daily_return
|
|
153
154
|
if target_pos := target_portfolio.positions_map.get(instrument_id, None):
|
|
154
155
|
target_weight = target_pos.weighting
|
|
155
156
|
if target_pos.shares is not None:
|
|
@@ -165,7 +166,8 @@ class TradingService:
|
|
|
165
166
|
currency=pos.currency,
|
|
166
167
|
price=Decimal(pos.price) if pos.price is not None else Decimal("0"),
|
|
167
168
|
currency_fx_rate=Decimal(pos.currency_fx_rate),
|
|
168
|
-
|
|
169
|
+
daily_return=Decimal(daily_return),
|
|
170
|
+
portfolio_contribution=effective_portfolio.portfolio_contribution,
|
|
169
171
|
)
|
|
170
172
|
trades.append(trade)
|
|
171
173
|
return TradeBatch(trades)
|