wbportfolio 1.48.0__py2.py3-none-any.whl → 1.49.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/factories/__init__.py +1 -3
- wbportfolio/factories/portfolios.py +0 -12
- wbportfolio/factories/product_groups.py +8 -1
- wbportfolio/factories/products.py +18 -0
- wbportfolio/factories/trades.py +5 -1
- wbportfolio/import_export/handlers/trade.py +8 -0
- wbportfolio/migrations/0075_portfolio_initial_position_date_and_more.py +28 -0
- wbportfolio/models/portfolio.py +8 -13
- wbportfolio/models/transactions/rebalancing.py +7 -1
- wbportfolio/models/transactions/trade_proposals.py +146 -49
- wbportfolio/models/transactions/trades.py +16 -11
- wbportfolio/pms/trading/handler.py +1 -1
- wbportfolio/pms/typing.py +3 -0
- wbportfolio/rebalancing/models/composite.py +14 -1
- wbportfolio/risk_management/backends/exposure_portfolio.py +30 -1
- wbportfolio/risk_management/tests/test_exposure_portfolio.py +47 -0
- wbportfolio/serializers/portfolios.py +26 -0
- wbportfolio/serializers/transactions/trades.py +13 -0
- wbportfolio/tests/models/test_portfolios.py +1 -1
- wbportfolio/tests/models/transactions/test_trade_proposals.py +381 -0
- wbportfolio/tests/models/transactions/test_trades.py +14 -0
- wbportfolio/tests/signals.py +1 -1
- wbportfolio/tests/viewsets/test_performances.py +2 -1
- wbportfolio/viewsets/configs/display/portfolios.py +58 -14
- wbportfolio/viewsets/configs/display/trades.py +23 -8
- wbportfolio/viewsets/configs/endpoints/trades.py +9 -0
- wbportfolio/viewsets/portfolios.py +22 -7
- wbportfolio/viewsets/transactions/trade_proposals.py +1 -1
- wbportfolio/viewsets/transactions/trades.py +86 -12
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/RECORD +33 -31
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.48.0.dist-info → wbportfolio-1.49.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,18 +9,16 @@ from .claim import (
|
|
|
9
9
|
from .custodians import CustodianFactory
|
|
10
10
|
from .dividends import DividendTransactionsFactory
|
|
11
11
|
from .fees import FeesFactory
|
|
12
|
-
from wbfdm.factories.instrument_prices import InstrumentPriceFactory
|
|
13
12
|
from .portfolios import (
|
|
14
13
|
InstrumentPortfolioThroughModelFactory,
|
|
15
14
|
ModelPortfolioFactory,
|
|
16
|
-
ModelPortfolioWithBaseProductFactory,
|
|
17
15
|
PortfolioFactory,
|
|
18
16
|
)
|
|
19
17
|
from .portfolio_swing_pricings import PortfolioSwingPricingFactory
|
|
20
18
|
from .portfolio_cash_targets import PortfolioCashTargetFactory
|
|
21
19
|
from .portfolio_cash_flow import DailyPortfolioCashFlowFactory
|
|
22
20
|
from .product_groups import ProductGroupFactory, ProductGroupRepresentantFactory
|
|
23
|
-
from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory
|
|
21
|
+
from .products import IndexProductFactory, ProductFactory, WhiteLabelProductFactory, ModelPortfolioWithBaseProductFactory
|
|
24
22
|
from .reconciliations import AccountReconciliationFactory, AccountReconciliationLineFactory
|
|
25
23
|
from .roles import ManagerPortfolioRoleFactory, ProductPortfolioRoleFactory
|
|
26
24
|
from .trades import CustomerTradeFactory, TradeFactory, TradeProposalFactory
|
|
@@ -9,8 +9,6 @@ from wbportfolio.models import (
|
|
|
9
9
|
PortfolioPortfolioThroughModel,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
from .products import ProductFactory
|
|
13
|
-
|
|
14
12
|
|
|
15
13
|
class PortfolioFactory(factory.django.DjangoModelFactory):
|
|
16
14
|
class Meta:
|
|
@@ -43,16 +41,6 @@ class ModelPortfolioFactory(PortfolioFactory):
|
|
|
43
41
|
)
|
|
44
42
|
|
|
45
43
|
|
|
46
|
-
class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
|
|
47
|
-
@factory.post_generation
|
|
48
|
-
def create_instrument(self, create, extracted, **kwargs):
|
|
49
|
-
if create:
|
|
50
|
-
instrument = ProductFactory.create()
|
|
51
|
-
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
52
|
-
instrument=instrument, defaults={"portfolio": self}
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
44
|
class InstrumentPortfolioThroughModelFactory(factory.django.DjangoModelFactory):
|
|
57
45
|
instrument = factory.SubFactory("wbportfolio.factories.ProductFactory")
|
|
58
46
|
portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import factory
|
|
2
2
|
from wbfdm.factories.instruments import InstrumentFactory
|
|
3
3
|
|
|
4
|
-
from wbportfolio.
|
|
4
|
+
from wbportfolio.factories import PortfolioFactory
|
|
5
|
+
from wbportfolio.models import InstrumentPortfolioThroughModel, ProductGroup, ProductGroupRepresentant
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class ProductGroupFactory(InstrumentFactory):
|
|
@@ -19,6 +20,12 @@ class ProductGroupFactory(InstrumentFactory):
|
|
|
19
20
|
auditor = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
|
|
20
21
|
paying_agent = factory.SubFactory("wbcore.contrib.directory.factories.entries.CompanyFactory")
|
|
21
22
|
|
|
23
|
+
@factory.post_generation
|
|
24
|
+
def create_initial_portfolio(product_group, *args, **kwargs):
|
|
25
|
+
if product_group.id and not product_group.portfolios.exists():
|
|
26
|
+
portfolio = PortfolioFactory.create()
|
|
27
|
+
InstrumentPortfolioThroughModel.objects.create(instrument=product_group, portfolio=portfolio)
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
class ProductGroupRepresentantFactory(factory.django.DjangoModelFactory):
|
|
24
31
|
class Meta:
|
|
@@ -4,6 +4,8 @@ import factory
|
|
|
4
4
|
from wbcore.contrib.directory.factories.entries import CompanyFactory
|
|
5
5
|
from wbfdm.factories.instruments import InstrumentFactory, InstrumentTypeFactory
|
|
6
6
|
|
|
7
|
+
from wbportfolio.factories import ModelPortfolioFactory, PortfolioFactory
|
|
8
|
+
from wbportfolio.models import InstrumentPortfolioThroughModel
|
|
7
9
|
from wbportfolio.models.products import (
|
|
8
10
|
AssetClass,
|
|
9
11
|
InvestmentIndex,
|
|
@@ -37,6 +39,12 @@ class ProductFactory(InstrumentFactory):
|
|
|
37
39
|
external_webpage = factory.Faker("url")
|
|
38
40
|
instrument_type = factory.LazyAttribute(lambda o: InstrumentTypeFactory.create(name="Product", key="product"))
|
|
39
41
|
|
|
42
|
+
@factory.post_generation
|
|
43
|
+
def create_initial_portfolio(product, *args, **kwargs):
|
|
44
|
+
if product.id and not product.portfolios.exists():
|
|
45
|
+
portfolio = PortfolioFactory.create()
|
|
46
|
+
InstrumentPortfolioThroughModel.objects.create(instrument=product, portfolio=portfolio)
|
|
47
|
+
|
|
40
48
|
# wbportfolio = factory.SubFactory(PortfolioFactory)
|
|
41
49
|
# portfolio_computed = factory.SubFactory(PortfolioFactory)
|
|
42
50
|
|
|
@@ -55,3 +63,13 @@ class WhiteLabelProductFactory(ProductFactory):
|
|
|
55
63
|
|
|
56
64
|
class IndexProductFactory(ProductFactory):
|
|
57
65
|
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ModelPortfolioWithBaseProductFactory(ModelPortfolioFactory):
|
|
69
|
+
@factory.post_generation
|
|
70
|
+
def create_instrument(self, create, extracted, **kwargs):
|
|
71
|
+
if create:
|
|
72
|
+
instrument = ProductFactory.create()
|
|
73
|
+
InstrumentPortfolioThroughModel.objects.update_or_create(
|
|
74
|
+
instrument=instrument, defaults={"portfolio": self}
|
|
75
|
+
)
|
wbportfolio/factories/trades.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import random
|
|
2
2
|
|
|
3
3
|
import factory
|
|
4
|
+
from faker import Faker
|
|
5
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
4
6
|
|
|
5
7
|
from wbportfolio.models import Trade, TradeProposal
|
|
6
8
|
|
|
7
9
|
from .transactions import TransactionFactory
|
|
8
10
|
|
|
11
|
+
fake = Faker()
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class TradeFactory(TransactionFactory):
|
|
11
15
|
class Meta:
|
|
@@ -22,7 +26,7 @@ class TradeProposalFactory(factory.django.DjangoModelFactory):
|
|
|
22
26
|
class Meta:
|
|
23
27
|
model = TradeProposal
|
|
24
28
|
|
|
25
|
-
trade_date = factory.
|
|
29
|
+
trade_date = factory.LazyAttribute(lambda o: (fake.date_object() + BDay(1)).date())
|
|
26
30
|
comment = factory.Faker("paragraph")
|
|
27
31
|
portfolio = factory.SubFactory("wbportfolio.factories.PortfolioFactory")
|
|
28
32
|
creator = factory.SubFactory("wbcore.contrib.directory.factories.PersonFactory")
|
|
@@ -24,6 +24,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
24
24
|
self.instrument_handler = InstrumentImportHandler(self.import_source)
|
|
25
25
|
self.register_handler = RegisterImportHandler(self.import_source)
|
|
26
26
|
self.currency_handler = CurrencyImportHandler(self.import_source)
|
|
27
|
+
self.trade_proposals = set()
|
|
27
28
|
|
|
28
29
|
def _data_changed(self, _object, change_data: Dict[str, Any], initial_data: Dict[str, Any], **kwargs):
|
|
29
30
|
if (new_register := change_data.get("register")) and (current_register := _object.register):
|
|
@@ -42,6 +43,7 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
42
43
|
|
|
43
44
|
if trade_proposal_id := data.pop("trade_proposal_id", None):
|
|
44
45
|
trade_proposal = TradeProposal.objects.get(id=trade_proposal_id)
|
|
46
|
+
self.trade_proposals.add(trade_proposal)
|
|
45
47
|
data["value_date"] = trade_proposal.last_effective_date
|
|
46
48
|
data["transaction_date"] = trade_proposal.trade_date
|
|
47
49
|
data["trade_proposal"] = trade_proposal
|
|
@@ -180,6 +182,12 @@ class TradeImportHandler(ImportExportHandler):
|
|
|
180
182
|
if instrument.instrument_type.key == "product":
|
|
181
183
|
update_outstanding_shares_as_task.delay(instrument.id)
|
|
182
184
|
|
|
185
|
+
# if the trade import relates to a trade proposal, we reset the TP after the import to ensure it contains the deleted positions (often forgotten by user)
|
|
186
|
+
for changed_trade_proposal in self.trade_proposals:
|
|
187
|
+
changed_trade_proposal.reset_trades(
|
|
188
|
+
target_portfolio=changed_trade_proposal._build_dto().convert_to_portfolio()
|
|
189
|
+
)
|
|
190
|
+
|
|
183
191
|
def _post_processing_updated_object(self, _object):
|
|
184
192
|
if _object.marked_for_deletion:
|
|
185
193
|
_object.marked_for_deletion = False
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.0.13 on 2025-03-20 08:19
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('wbportfolio', '0074_alter_rebalancer_frequency_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='portfolio',
|
|
15
|
+
name='initial_position_date',
|
|
16
|
+
field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='portfolio',
|
|
20
|
+
name='last_position_date',
|
|
21
|
+
field=models.DateField(blank=True, null=True, verbose_name='Last Position Date'),
|
|
22
|
+
),
|
|
23
|
+
migrations.AlterField(
|
|
24
|
+
model_name='trade',
|
|
25
|
+
name='transaction_subtype',
|
|
26
|
+
field=models.CharField(choices=[('REBALANCE', 'Rebalance'), ('DECREASE', 'Decrease'), ('INCREASE', 'Increase'), ('SUBSCRIPTION', 'Subscription'), ('REDEMPTION', 'Redemption'), ('BUY', 'Buy'), ('SELL', 'Sell'), ('NO_CHANGE', 'No Change')], default='BUY', max_length=32, verbose_name='Trade Type'),
|
|
27
|
+
),
|
|
28
|
+
]
|
wbportfolio/models/portfolio.py
CHANGED
|
@@ -29,7 +29,6 @@ from django.dispatch import receiver
|
|
|
29
29
|
from django.utils import timezone
|
|
30
30
|
from django.utils.functional import cached_property
|
|
31
31
|
from pandas._libs.tslibs.offsets import BDay
|
|
32
|
-
from psycopg.types.range import DateRange
|
|
33
32
|
from skfolio.preprocessing import prices_to_returns
|
|
34
33
|
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
35
34
|
from wbcore.contrib.notifications.utils import create_notification_type
|
|
@@ -293,6 +292,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
293
292
|
default=False, help_text="If true, this portfolio is a composition of other portfolio"
|
|
294
293
|
)
|
|
295
294
|
updated_at = models.DateTimeField(blank=True, null=True, verbose_name="Updated At")
|
|
295
|
+
last_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
|
|
296
|
+
initial_position_date = models.DateField(blank=True, null=True, verbose_name="Last Position Date")
|
|
297
|
+
|
|
296
298
|
bank_accounts = models.ManyToManyField(
|
|
297
299
|
to="directory.BankingContact",
|
|
298
300
|
related_name="wbportfolio_portfolios",
|
|
@@ -699,6 +701,11 @@ class Portfolio(DeleteToDisableMixin, WBModel):
|
|
|
699
701
|
self.evaluate_rebalancing(val_date)
|
|
700
702
|
|
|
701
703
|
self.updated_at = timezone.now()
|
|
704
|
+
if self.assets.filter(date=val_date).exists():
|
|
705
|
+
if not self.last_position_date or self.last_position_date < val_date:
|
|
706
|
+
self.last_position_date = val_date
|
|
707
|
+
if not self.initial_position_date or self.initial_position_date > val_date:
|
|
708
|
+
self.initial_position_date = val_date
|
|
702
709
|
self.save()
|
|
703
710
|
|
|
704
711
|
if compute_metrics:
|
|
@@ -1259,18 +1266,6 @@ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float
|
|
|
1259
1266
|
return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
|
|
1260
1267
|
|
|
1261
1268
|
|
|
1262
|
-
@receiver(post_save, sender="wbportfolio.Product")
|
|
1263
|
-
@receiver(post_save, sender="wbportfolio.ProductGroup")
|
|
1264
|
-
def post_product_creation(sender, instance, created, raw, **kwargs):
|
|
1265
|
-
if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
|
|
1266
|
-
portfolio = Portfolio.objects.create(
|
|
1267
|
-
name=f"Portfolio: {instance.name}",
|
|
1268
|
-
currency=instance.currency,
|
|
1269
|
-
invested_timespan=DateRange(instance.inception_date if instance.inception_date else date.min, date.max),
|
|
1270
|
-
)
|
|
1271
|
-
InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
1269
|
@receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
|
|
1275
1270
|
def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
|
|
1276
1271
|
if (
|
|
@@ -133,7 +133,7 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
133
133
|
trade_proposal.reset_trades(target_portfolio)
|
|
134
134
|
trade_proposal.submit()
|
|
135
135
|
if self.approve_trade_proposal_automatically and self.portfolio.can_be_rebalanced:
|
|
136
|
-
trade_proposal.approve()
|
|
136
|
+
trade_proposal.approve(replay=False)
|
|
137
137
|
except ValidationError:
|
|
138
138
|
# If we encountered a validation error, we set the trade proposal as failed
|
|
139
139
|
trade_proposal.status = TradeProposal.Status.FAILED
|
|
@@ -146,6 +146,12 @@ class Rebalancer(ComplexToStringMixin, models.Model):
|
|
|
146
146
|
def rrule(self):
|
|
147
147
|
return self.get_rrule()
|
|
148
148
|
|
|
149
|
+
def get_next_rebalancing_date(self, pivot_date: date) -> date | None:
|
|
150
|
+
for _dt in self.rrule:
|
|
151
|
+
_d = _dt.date()
|
|
152
|
+
if _d > pivot_date:
|
|
153
|
+
return _d
|
|
154
|
+
|
|
149
155
|
@property
|
|
150
156
|
def frequency_repr(self):
|
|
151
157
|
return humanize_rrule(self.rrule)
|
|
@@ -11,12 +11,13 @@ from django.utils.functional import cached_property
|
|
|
11
11
|
from django_fsm import FSMField, transition
|
|
12
12
|
from pandas._libs.tslibs.offsets import BDay
|
|
13
13
|
from wbcompliance.models.risk_management.mixins import RiskCheckMixin
|
|
14
|
+
from wbcore.contrib.currency.models import Currency
|
|
14
15
|
from wbcore.contrib.icons import WBIcon
|
|
15
16
|
from wbcore.enums import RequestType
|
|
16
17
|
from wbcore.metadata.configs.buttons import ActionButton
|
|
17
18
|
from wbcore.models import WBModel
|
|
18
19
|
from wbcore.utils.models import CloneMixin
|
|
19
|
-
from wbfdm.models.instruments.instruments import Instrument
|
|
20
|
+
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
20
21
|
|
|
21
22
|
from wbportfolio.models.roles import PortfolioRole
|
|
22
23
|
from wbportfolio.pms.trading import TradingService
|
|
@@ -89,6 +90,10 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
89
90
|
def check_evaluation_date(self):
|
|
90
91
|
return self.trade_date
|
|
91
92
|
|
|
93
|
+
@cached_property
|
|
94
|
+
def portfolio_total_asset_value(self) -> Decimal:
|
|
95
|
+
return self.portfolio.get_total_asset_value(self.last_effective_date)
|
|
96
|
+
|
|
92
97
|
@cached_property
|
|
93
98
|
def validated_trading_service(self) -> TradingService:
|
|
94
99
|
"""
|
|
@@ -96,8 +101,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
96
101
|
"""
|
|
97
102
|
return TradingService(
|
|
98
103
|
self.trade_date,
|
|
99
|
-
effective_portfolio=self.portfolio._build_dto(self.
|
|
100
|
-
|
|
104
|
+
effective_portfolio=self.portfolio._build_dto(self.last_effective_date),
|
|
105
|
+
target_portfolio=self._build_dto().convert_to_portfolio(),
|
|
101
106
|
)
|
|
102
107
|
|
|
103
108
|
@cached_property
|
|
@@ -150,9 +155,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
150
155
|
Returns:
|
|
151
156
|
DTO trade object
|
|
152
157
|
"""
|
|
153
|
-
return (
|
|
154
|
-
TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()])) if self.trades.exists() else None
|
|
155
|
-
)
|
|
158
|
+
return TradeBatchDTO(tuple([trade._build_dto() for trade in self.trades.all()]))
|
|
156
159
|
|
|
157
160
|
# Start tools methods
|
|
158
161
|
def _clone(self, **kwargs) -> SelfTradeProposal:
|
|
@@ -197,7 +200,6 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
197
200
|
with suppress(Trade.DoesNotExist):
|
|
198
201
|
trade = self.trades.get(underlying_instrument_id=underlying_instrument_id)
|
|
199
202
|
trade.weighting = round(trade_dto.delta_weight, 6)
|
|
200
|
-
trade.shares = self.estimate_shares(trade)
|
|
201
203
|
trade.save()
|
|
202
204
|
total_target_weight += trade._target_weight
|
|
203
205
|
leftovers_trades = leftovers_trades.exclude(id=trade.id)
|
|
@@ -208,7 +210,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
208
210
|
biggest_trade.weighting -= quantize_error
|
|
209
211
|
biggest_trade.save()
|
|
210
212
|
|
|
211
|
-
def
|
|
213
|
+
def _get_default_target_portfolio(self, **kwargs) -> PortfolioDTO:
|
|
212
214
|
if self.rebalancing_model:
|
|
213
215
|
params = {}
|
|
214
216
|
if rebalancer := getattr(self.portfolio, "automatic_rebalancer", None):
|
|
@@ -217,50 +219,57 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
217
219
|
return self.rebalancing_model.get_target_portfolio(
|
|
218
220
|
self.portfolio, self.trade_date, self.last_effective_date, **params
|
|
219
221
|
)
|
|
222
|
+
if self.trades.exists():
|
|
223
|
+
return self._build_dto().convert_to_portfolio()
|
|
220
224
|
# Return the current portfolio by default
|
|
221
225
|
return self.portfolio._build_dto(self.last_effective_date)
|
|
222
226
|
|
|
223
|
-
def reset_trades(self, target_portfolio: PortfolioDTO | None = None):
|
|
227
|
+
def reset_trades(self, target_portfolio: PortfolioDTO | None = None, validate_trade: bool = True):
|
|
224
228
|
"""
|
|
225
229
|
Will delete all existing trades and recreate them from the method `create_or_update_trades`
|
|
226
230
|
"""
|
|
227
231
|
if self.status != TradeProposal.Status.DRAFT:
|
|
228
232
|
raise ValueError("Cannot reset non-draft trade proposal. Revert this trade proposal first.")
|
|
229
233
|
# delete all existing trades
|
|
230
|
-
self.trades.all().delete()
|
|
231
234
|
last_effective_date = self.last_effective_date
|
|
232
235
|
# Get effective and target portfolio
|
|
233
236
|
effective_portfolio = self.portfolio._build_dto(last_effective_date)
|
|
234
237
|
if not target_portfolio:
|
|
235
|
-
target_portfolio = self.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)
|
|
243
|
-
service.normalize()
|
|
244
|
-
service.is_valid()
|
|
245
|
-
for trade_dto in service.validated_trades:
|
|
246
|
-
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
247
|
-
currency_fx_rate = instrument.currency.convert(
|
|
248
|
-
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
249
|
-
)
|
|
250
|
-
trade = Trade(
|
|
251
|
-
underlying_instrument=instrument,
|
|
252
|
-
transaction_subtype=Trade.Type.BUY if trade_dto.delta_weight > 0 else Trade.Type.SELL,
|
|
253
|
-
currency=instrument.currency,
|
|
254
|
-
value_date=last_effective_date,
|
|
255
|
-
transaction_date=self.trade_date,
|
|
256
|
-
trade_proposal=self,
|
|
257
|
-
portfolio=self.portfolio,
|
|
258
|
-
weighting=trade_dto.delta_weight,
|
|
259
|
-
status=Trade.Status.DRAFT,
|
|
260
|
-
currency_fx_rate=currency_fx_rate,
|
|
238
|
+
target_portfolio = self._get_default_target_portfolio()
|
|
239
|
+
|
|
240
|
+
if target_portfolio:
|
|
241
|
+
service = TradingService(
|
|
242
|
+
self.trade_date,
|
|
243
|
+
effective_portfolio=effective_portfolio,
|
|
244
|
+
target_portfolio=target_portfolio,
|
|
261
245
|
)
|
|
262
|
-
|
|
263
|
-
|
|
246
|
+
if validate_trade:
|
|
247
|
+
service.normalize()
|
|
248
|
+
service.is_valid()
|
|
249
|
+
trades = service.validated_trades
|
|
250
|
+
else:
|
|
251
|
+
trades = service.trades_batch.trades_map.values()
|
|
252
|
+
for trade_dto in trades:
|
|
253
|
+
instrument = Instrument.objects.get(id=trade_dto.underlying_instrument)
|
|
254
|
+
currency_fx_rate = instrument.currency.convert(
|
|
255
|
+
last_effective_date, self.portfolio.currency, exact_lookup=True
|
|
256
|
+
)
|
|
257
|
+
# we cannot do a bulk-create because Trade is a multi table inheritance
|
|
258
|
+
try:
|
|
259
|
+
trade = self.trades.get(underlying_instrument=instrument)
|
|
260
|
+
except Trade.DoesNotExist:
|
|
261
|
+
trade = Trade(
|
|
262
|
+
underlying_instrument=instrument,
|
|
263
|
+
currency=instrument.currency,
|
|
264
|
+
value_date=last_effective_date,
|
|
265
|
+
transaction_date=self.trade_date,
|
|
266
|
+
trade_proposal=self,
|
|
267
|
+
portfolio=self.portfolio,
|
|
268
|
+
weighting=trade_dto.delta_weight,
|
|
269
|
+
status=Trade.Status.DRAFT,
|
|
270
|
+
currency_fx_rate=currency_fx_rate,
|
|
271
|
+
)
|
|
272
|
+
trade.save()
|
|
264
273
|
|
|
265
274
|
def replay(self):
|
|
266
275
|
last_trade_proposal = self
|
|
@@ -275,12 +284,15 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
275
284
|
if last_trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
276
285
|
if self.rebalancing_model: # if there is no position (for any reason) or we the trade proposal has a rebalancer model attached (trades are computed based on an aglo), we reapply this trade proposal
|
|
277
286
|
logger.info(f"Resetting trades from rebalancer model {self.rebalancing_model} ...")
|
|
278
|
-
|
|
287
|
+
with suppress(
|
|
288
|
+
ValidationError
|
|
289
|
+
): # we silent any validation error while setting proposal, because if this happens, we assume the current trade proposal state if valid and we continue to batch compute
|
|
290
|
+
self.reset_trades()
|
|
279
291
|
logger.info("Submitting trade proposal ...")
|
|
280
292
|
last_trade_proposal.submit()
|
|
281
293
|
if last_trade_proposal.status == TradeProposal.Status.SUBMIT:
|
|
282
294
|
logger.info("Approving trade proposal ...")
|
|
283
|
-
last_trade_proposal.approve()
|
|
295
|
+
last_trade_proposal.approve(replay=False)
|
|
284
296
|
last_trade_proposal.save()
|
|
285
297
|
next_trade_proposal = last_trade_proposal.next_trade_proposal
|
|
286
298
|
next_trade_date = (
|
|
@@ -291,14 +303,79 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
291
303
|
)
|
|
292
304
|
last_trade_proposal = overriding_trade_proposal or next_trade_proposal
|
|
293
305
|
|
|
294
|
-
def
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
306
|
+
def get_estimated_shares(self, weight: Decimal, underlying_quote: Instrument) -> Decimal | None:
|
|
307
|
+
"""
|
|
308
|
+
Estimates the number of shares for a trade based on the given weight and underlying quote.
|
|
309
|
+
|
|
310
|
+
This method calculates the estimated shares by dividing the trade's total value in the portfolio's currency by the price of the underlying quote in the same currency. It handles currency conversion and suppresses any ValueError that might occur during the price retrieval.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
weight (Decimal): The weight of the trade.
|
|
314
|
+
underlying_quote (Instrument): The underlying instrument for the trade.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Decimal | None: The estimated number of shares or None if the calculation fails.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
# Retrieve the price of the underlying quote on the trade date TODO: this is very slow and probably due to the to_date argument to the dl which slowdown drastically the query
|
|
321
|
+
quote_price = Decimal(underlying_quote.get_price(self.trade_date))
|
|
322
|
+
|
|
323
|
+
# Calculate the trade's total value in the portfolio's currency
|
|
324
|
+
trade_total_value_fx_portfolio = self.portfolio_total_asset_value * weight
|
|
325
|
+
|
|
326
|
+
# Convert the quote price to the portfolio's currency
|
|
327
|
+
price_fx_portfolio = quote_price * underlying_quote.currency.convert(
|
|
328
|
+
self.trade_date, self.portfolio.currency, exact_lookup=False
|
|
298
329
|
)
|
|
299
|
-
|
|
330
|
+
|
|
331
|
+
# If the price is valid, calculate and return the estimated shares
|
|
300
332
|
if price_fx_portfolio:
|
|
301
333
|
return trade_total_value_fx_portfolio / price_fx_portfolio
|
|
334
|
+
except Exception:
|
|
335
|
+
# Suppress any ValueError and return None if the calculation fails
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def get_estimated_target_cash(self, currency: Currency) -> tuple[Decimal, Decimal]:
|
|
339
|
+
"""
|
|
340
|
+
Estimates the target cash weight and shares for a trade proposal.
|
|
341
|
+
|
|
342
|
+
This method calculates the target cash weight by summing the weights of cash trades and adding any leftover weight from non-cash trades. It then estimates the target shares for this cash component if the portfolio is not only weighting-based.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
currency (Currency): The currency for the target currency component
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
tuple[Decimal, Decimal]: A tuple containing the target cash weight and the estimated target shares.
|
|
349
|
+
"""
|
|
350
|
+
# Retrieve trades with base information
|
|
351
|
+
trades = self.trades.all().annotate_base_info()
|
|
352
|
+
|
|
353
|
+
# Calculate the target cash weight from cash trades
|
|
354
|
+
target_cash_weight = trades.filter(
|
|
355
|
+
underlying_instrument__is_cash=True, underlying_instrument__currency=currency
|
|
356
|
+
).aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
357
|
+
# if the specified currency match the portfolio's currency, we include the weight leftover to this cash compoenent
|
|
358
|
+
if currency == self.portfolio.currency:
|
|
359
|
+
# Calculate the total target weight of all trades
|
|
360
|
+
total_target_weight = trades.aggregate(s=models.Sum("target_weight"))["s"] or Decimal(0)
|
|
361
|
+
|
|
362
|
+
# Add any leftover weight as cash
|
|
363
|
+
target_cash_weight += Decimal(1) - total_target_weight
|
|
364
|
+
|
|
365
|
+
# Initialize target shares to zero
|
|
366
|
+
total_target_shares = Decimal(0)
|
|
367
|
+
|
|
368
|
+
# If the portfolio is not only weighting-based, estimate the target shares for the cash component
|
|
369
|
+
if not self.portfolio.only_weighting:
|
|
370
|
+
# Get or create a cash component for the portfolio's currency
|
|
371
|
+
cash_component = Cash.objects.get_or_create(
|
|
372
|
+
currency=currency, defaults={"is_cash": True, "name": currency.title}
|
|
373
|
+
)[0]
|
|
374
|
+
|
|
375
|
+
# Estimate the target shares for the cash component
|
|
376
|
+
total_target_shares = self.get_estimated_shares(target_cash_weight, cash_component)
|
|
377
|
+
|
|
378
|
+
return target_cash_weight, total_target_shares
|
|
302
379
|
|
|
303
380
|
# Start FSM logics
|
|
304
381
|
|
|
@@ -323,9 +400,26 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
323
400
|
)
|
|
324
401
|
def submit(self, by=None, description=None, **kwargs):
|
|
325
402
|
self.trades.update(comment="", status=Trade.Status.DRAFT)
|
|
403
|
+
self.reset_trades(target_portfolio=self._build_dto().convert_to_portfolio())
|
|
326
404
|
for trade in self.trades.all():
|
|
327
405
|
trade.submit()
|
|
328
406
|
trade.save()
|
|
407
|
+
|
|
408
|
+
# If we estimate cash on this trade proposal, we make sure to create the corresponding cash component
|
|
409
|
+
cash_target_cash_weight, cash_target_cash_shares = self.get_estimated_target_cash(self.portfolio.currency)
|
|
410
|
+
if cash_target_cash_weight:
|
|
411
|
+
cash_component = Cash.objects.get_or_create(
|
|
412
|
+
currency=self.portfolio.currency, defaults={"name": self.portfolio.currency.title}
|
|
413
|
+
)[0]
|
|
414
|
+
self.trades.update_or_create(
|
|
415
|
+
underlying_instrument=cash_component,
|
|
416
|
+
defaults={
|
|
417
|
+
"status": Trade.Status.SUBMIT,
|
|
418
|
+
"weighting": cash_target_cash_weight,
|
|
419
|
+
"shares": cash_target_cash_shares,
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
|
|
329
423
|
self.evaluate_active_rules(
|
|
330
424
|
self.trade_date, self.validated_trading_service.target_portfolio, asynchronously=True
|
|
331
425
|
)
|
|
@@ -356,7 +450,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
356
450
|
|
|
357
451
|
@property
|
|
358
452
|
def can_be_approved_or_denied(self):
|
|
359
|
-
return self.
|
|
453
|
+
return not self.has_non_successful_checks and self.portfolio.is_manageable
|
|
360
454
|
|
|
361
455
|
@transition(
|
|
362
456
|
field=status,
|
|
@@ -378,7 +472,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
378
472
|
)
|
|
379
473
|
},
|
|
380
474
|
)
|
|
381
|
-
def approve(self, by=None, description=None,
|
|
475
|
+
def approve(self, by=None, description=None, replay: bool = True, **kwargs):
|
|
382
476
|
# We validate trade which will create or update the initial asset positions
|
|
383
477
|
if not self.portfolio.can_be_rebalanced:
|
|
384
478
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
@@ -387,6 +481,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
387
481
|
for trade in self.trades.all():
|
|
388
482
|
trade.execute()
|
|
389
483
|
trade.save()
|
|
484
|
+
if replay and self.portfolio.is_manageable:
|
|
485
|
+
replay_as_task.delay(self.id)
|
|
390
486
|
|
|
391
487
|
def can_approve(self):
|
|
392
488
|
errors = dict()
|
|
@@ -398,7 +494,7 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
398
494
|
errors["portfolio"] = (
|
|
399
495
|
"The portfolio needs to be a model portfolio in order to approve this trade proposal manually"
|
|
400
496
|
)
|
|
401
|
-
if self.
|
|
497
|
+
if self.has_non_successful_checks:
|
|
402
498
|
errors["non_field_errors"] = "The pre trades rules did not passed successfully"
|
|
403
499
|
return errors
|
|
404
500
|
|
|
@@ -440,7 +536,8 @@ class TradeProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
440
536
|
permission=lambda instance, user: PortfolioRole.is_portfolio_manager(
|
|
441
537
|
user.profile, portfolio=instance.portfolio
|
|
442
538
|
)
|
|
443
|
-
and instance.has_all_check_completed
|
|
539
|
+
and instance.has_all_check_completed
|
|
540
|
+
or not instance.checks.exists(), # we wait for all checks to succeed before proposing the back to draft transition
|
|
444
541
|
custom={
|
|
445
542
|
"_transition_button": ActionButton(
|
|
446
543
|
method=RequestType.PATCH,
|