wbportfolio 1.54.20__py2.py3-none-any.whl → 1.54.21__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/migrations/0084_orderproposal_min_order_value.py +25 -0
- wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
- wbportfolio/models/asset.py +4 -141
- wbportfolio/models/builder.py +258 -0
- wbportfolio/models/orders/order_proposals.py +39 -33
- wbportfolio/models/orders/orders.py +18 -21
- wbportfolio/models/portfolio.py +108 -232
- wbportfolio/serializers/orders/order_proposals.py +1 -0
- wbportfolio/tests/models/orders/test_order_proposals.py +16 -1
- wbportfolio/tests/models/test_portfolios.py +34 -121
- wbportfolio/viewsets/orders/configs/displays/order_proposals.py +6 -5
- wbportfolio/viewsets/orders/orders.py +1 -1
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.21.dist-info}/METADATA +1 -1
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.21.dist-info}/RECORD +16 -13
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.21.dist-info}/WHEEL +0 -0
- {wbportfolio-1.54.20.dist-info → wbportfolio-1.54.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-07-30 14:25
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('wbportfolio', '0083_order_alter_trade_options_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='orderproposal',
|
|
15
|
+
name='min_order_value',
|
|
16
|
+
field=models.IntegerField(default=0, help_text='Minimum Order Value in the Portfolio currency', verbose_name='Minimum Order Value'),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='order',
|
|
20
|
+
name='desired_target_weight',
|
|
21
|
+
field=models.DecimalField(decimal_places=8, default=Decimal('0'),
|
|
22
|
+
help_text='Desired Target Weight (for compliance and audit)', max_digits=9,
|
|
23
|
+
verbose_name='Desired Target Weight'),
|
|
24
|
+
),
|
|
25
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Generated by Django 5.0.14 on 2025-07-30 14:40
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
|
|
7
|
+
def migrate_desired_target_weight(apps, schema_editor):
|
|
8
|
+
from wbportfolio.models.orders import Order, OrderProposal
|
|
9
|
+
objs = []
|
|
10
|
+
qs = OrderProposal.objects.filter(orders__isnull=False).distinct()
|
|
11
|
+
for op in tqdm(qs, total=qs.count()):
|
|
12
|
+
for o in op.get_orders():
|
|
13
|
+
o.desired_target_weight = o._target_weight
|
|
14
|
+
objs.append(o)
|
|
15
|
+
Order.objects.bulk_update(objs, ["desired_target_weight"], batch_size=10000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
('wbportfolio', '0084_orderproposal_min_order_value'),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
operations = [
|
|
25
|
+
migrations.RunPython(migrate_desired_target_weight)
|
|
26
|
+
]
|
wbportfolio/models/asset.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import logging
|
|
2
2
|
from contextlib import suppress
|
|
3
3
|
from datetime import date
|
|
4
4
|
from decimal import Decimal, InvalidOperation
|
|
@@ -43,6 +43,8 @@ from wbportfolio.models.portfolio_relationship import (
|
|
|
43
43
|
from wbportfolio.models.roles import PortfolioRole
|
|
44
44
|
from wbportfolio.pms.typing import Position as PositionDTO
|
|
45
45
|
|
|
46
|
+
logger = logging.getLogger("pms")
|
|
47
|
+
|
|
46
48
|
MARKETCAP_S = 2_000_000_000
|
|
47
49
|
MARKETCAP_M = 10_000_000_000
|
|
48
50
|
MARKETCAP_L = 50_000_000_000
|
|
@@ -55,146 +57,7 @@ HOUR = MINUTE * 60
|
|
|
55
57
|
DAY = HOUR * 24
|
|
56
58
|
|
|
57
59
|
if TYPE_CHECKING:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class AssetPositionIterator:
|
|
62
|
-
"""
|
|
63
|
-
Efficiently converts position data into AssetPosition models with batch operations
|
|
64
|
-
and proper dependency management.
|
|
65
|
-
|
|
66
|
-
Features:
|
|
67
|
-
- Bulk database fetching for performance
|
|
68
|
-
- Thread-safe operations
|
|
69
|
-
- Clear type hints
|
|
70
|
-
- Memory-efficient storage
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
positions: dict[tuple[date, int, int | None], "AssetPosition"]
|
|
74
|
-
|
|
75
|
-
_prices: dict[date, dict[int, float]]
|
|
76
|
-
_weights: dict[date, dict[int, float]]
|
|
77
|
-
_fx_rates: dict[date, dict[Currency, CurrencyFXRates]]
|
|
78
|
-
_instruments: dict[int, Instrument]
|
|
79
|
-
|
|
80
|
-
def __init__(
|
|
81
|
-
self,
|
|
82
|
-
portfolio: "Portfolio",
|
|
83
|
-
prices: dict[date, dict[int, float]] | None = None,
|
|
84
|
-
infer_underlying_quote_price: bool = False,
|
|
85
|
-
):
|
|
86
|
-
self.portfolio = portfolio
|
|
87
|
-
self.infer_underlying_quote_price = infer_underlying_quote_price
|
|
88
|
-
# Initialize data stores with type hints
|
|
89
|
-
self._instruments = {}
|
|
90
|
-
self._fx_rates = defaultdict(dict)
|
|
91
|
-
self._weights = defaultdict(dict)
|
|
92
|
-
self._prices = prices or defaultdict(dict)
|
|
93
|
-
self.positions = dict()
|
|
94
|
-
|
|
95
|
-
def _get_instrument(self, instrument_id: int) -> Instrument:
|
|
96
|
-
try:
|
|
97
|
-
return self._instruments[instrument_id]
|
|
98
|
-
except KeyError:
|
|
99
|
-
instrument = Instrument.objects.get(id=instrument_id)
|
|
100
|
-
self._instruments[instrument_id] = instrument
|
|
101
|
-
return instrument
|
|
102
|
-
|
|
103
|
-
def _get_fx_rate(self, val_date: date, currency: Currency) -> CurrencyFXRates | None:
|
|
104
|
-
try:
|
|
105
|
-
return self._fx_rates[val_date][currency]
|
|
106
|
-
except KeyError:
|
|
107
|
-
with suppress(CurrencyFXRates.DoesNotExist):
|
|
108
|
-
fx_rate = CurrencyFXRates.objects.get(
|
|
109
|
-
currency=currency, date=val_date
|
|
110
|
-
) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
|
|
111
|
-
self._fx_rates[val_date][currency] = fx_rate
|
|
112
|
-
return fx_rate
|
|
113
|
-
|
|
114
|
-
def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
|
|
115
|
-
try:
|
|
116
|
-
return self._prices[val_date][instrument.id]
|
|
117
|
-
except KeyError:
|
|
118
|
-
return None
|
|
119
|
-
|
|
120
|
-
def _dict_to_model(self, val_date: date, instrument_id: int, weighting: float, **kwargs) -> "AssetPosition":
|
|
121
|
-
underlying_quote = self._get_instrument(instrument_id)
|
|
122
|
-
currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
|
|
123
|
-
currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
|
|
124
|
-
price = self._get_price(val_date, underlying_quote)
|
|
125
|
-
|
|
126
|
-
parameters = dict(
|
|
127
|
-
underlying_quote=underlying_quote,
|
|
128
|
-
weighting=round(weighting, 8),
|
|
129
|
-
date=val_date,
|
|
130
|
-
asset_valuation_date=val_date,
|
|
131
|
-
is_estimated=True,
|
|
132
|
-
portfolio=self.portfolio,
|
|
133
|
-
currency=underlying_quote.currency,
|
|
134
|
-
initial_price=price,
|
|
135
|
-
currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
|
|
136
|
-
currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
|
|
137
|
-
initial_currency_fx_rate=None,
|
|
138
|
-
underlying_quote_price=None,
|
|
139
|
-
underlying_instrument=None,
|
|
140
|
-
)
|
|
141
|
-
parameters.update(kwargs)
|
|
142
|
-
position = AssetPosition(**parameters)
|
|
143
|
-
position.pre_save(
|
|
144
|
-
infer_underlying_quote_price=self.infer_underlying_quote_price
|
|
145
|
-
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
146
|
-
return position
|
|
147
|
-
|
|
148
|
-
def add(self, positions: list["AssetPosition"] | tuple[date, dict[int, float]], **kwargs):
|
|
149
|
-
"""
|
|
150
|
-
Add multiple positions efficiently with batch processing
|
|
151
|
-
|
|
152
|
-
Args:
|
|
153
|
-
positions: Iterable of AssetPosition instances or dictionary of weight {instrument_id: weight} that needs to be converted into AssetPosition
|
|
154
|
-
"""
|
|
155
|
-
if isinstance(positions, tuple):
|
|
156
|
-
val_date = positions[0]
|
|
157
|
-
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
158
|
-
for position in positions:
|
|
159
|
-
if not isinstance(position, AssetPosition):
|
|
160
|
-
position = self._dict_to_model(*position, **kwargs)
|
|
161
|
-
|
|
162
|
-
# Generate unique composite key
|
|
163
|
-
key = (
|
|
164
|
-
position.date,
|
|
165
|
-
position.underlying_quote.id,
|
|
166
|
-
position.portfolio_created.id if position.portfolio_created else None,
|
|
167
|
-
)
|
|
168
|
-
# Merge duplicate positions
|
|
169
|
-
if existing_position := self.positions.get(key):
|
|
170
|
-
position.weighting += existing_position.weighting
|
|
171
|
-
if existing_position.initial_shares:
|
|
172
|
-
position.initial_shares += existing_position.initial_shares
|
|
173
|
-
# ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
|
|
174
|
-
position.portfolio = self.portfolio
|
|
175
|
-
if position.initial_price is not None and position.initial_currency_fx_rate is not None:
|
|
176
|
-
self.positions[key] = position
|
|
177
|
-
self._weights[position.date][position.underlying_quote.id] = float(position.weighting)
|
|
178
|
-
|
|
179
|
-
return self
|
|
180
|
-
|
|
181
|
-
def get_dates(self) -> list[date]:
|
|
182
|
-
"""Get sorted list of unique dates"""
|
|
183
|
-
return list(self._weights.keys())
|
|
184
|
-
|
|
185
|
-
def get_weights(self) -> dict[date, dict[int, float]]:
|
|
186
|
-
"""Get weight structure with instrument IDs as keys"""
|
|
187
|
-
return dict(self._weights)
|
|
188
|
-
|
|
189
|
-
def __iter__(self):
|
|
190
|
-
# return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
|
|
191
|
-
yield from filter(lambda a: not a.portfolio.is_manageable or a.weighting, self.positions.values())
|
|
192
|
-
|
|
193
|
-
def __getitem__(self, item: tuple[date, Instrument]) -> float:
|
|
194
|
-
return self._weights[item[0]][item[1].id]
|
|
195
|
-
|
|
196
|
-
def __bool__(self) -> bool:
|
|
197
|
-
return len(self.positions.keys()) > 0
|
|
60
|
+
pass
|
|
198
61
|
|
|
199
62
|
|
|
200
63
|
class AssetPositionDefaultQueryset(QuerySet):
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from datetime import date
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from celery import chain, group
|
|
9
|
+
from django.contrib.contenttypes.models import ContentType
|
|
10
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
11
|
+
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
12
|
+
from wbfdm.contrib.metric.tasks import compute_metrics_as_task
|
|
13
|
+
from wbfdm.models import Instrument
|
|
14
|
+
|
|
15
|
+
from wbportfolio.models.asset import AssetPosition
|
|
16
|
+
from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from wbportfolio.models import Portfolio
|
|
20
|
+
logger = logging.getLogger("pms")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AssetPositionBuilder:
|
|
24
|
+
"""
|
|
25
|
+
Efficiently converts position data into AssetPosition models with batch operations
|
|
26
|
+
and proper dependency management.
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- Bulk database fetching for performance
|
|
30
|
+
- Thread-safe operations
|
|
31
|
+
- Clear type hints
|
|
32
|
+
- Memory-efficient storage
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_positions: dict[date, dict[tuple[int, int | None], "AssetPosition"]]
|
|
36
|
+
|
|
37
|
+
_prices: dict[date, dict[int, float]]
|
|
38
|
+
_fx_rates: dict[date, dict[Currency, CurrencyFXRates]]
|
|
39
|
+
_instruments: dict[int, Instrument]
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
portfolio: "Portfolio",
|
|
44
|
+
):
|
|
45
|
+
self.portfolio = portfolio
|
|
46
|
+
# Initialize data stores with type hints
|
|
47
|
+
self._instruments = {}
|
|
48
|
+
self._fx_rates = defaultdict(dict)
|
|
49
|
+
self.prices = defaultdict(dict)
|
|
50
|
+
self.returns = pd.DataFrame()
|
|
51
|
+
self._compute_metrics_tasks = set()
|
|
52
|
+
self._change_at_date_tasks = dict()
|
|
53
|
+
self._positions = defaultdict(dict)
|
|
54
|
+
|
|
55
|
+
def get_positions(self, **kwargs):
|
|
56
|
+
# return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
|
|
57
|
+
for _, positions in self._positions.items():
|
|
58
|
+
for _, position in positions.items():
|
|
59
|
+
if not position.portfolio.is_manageable or position.weighting:
|
|
60
|
+
for k, v in kwargs.items():
|
|
61
|
+
setattr(position, k, v)
|
|
62
|
+
yield position
|
|
63
|
+
|
|
64
|
+
def __bool__(self) -> bool:
|
|
65
|
+
return len(self._positions.keys()) > 0
|
|
66
|
+
|
|
67
|
+
def _get_instrument(self, instrument_id: int) -> Instrument:
|
|
68
|
+
try:
|
|
69
|
+
return self._instruments[instrument_id]
|
|
70
|
+
except KeyError:
|
|
71
|
+
instrument = Instrument.objects.get(id=instrument_id)
|
|
72
|
+
self._instruments[instrument_id] = instrument
|
|
73
|
+
return instrument
|
|
74
|
+
|
|
75
|
+
def _get_fx_rate(self, val_date: date, currency: Currency) -> CurrencyFXRates | None:
|
|
76
|
+
try:
|
|
77
|
+
return self._fx_rates[val_date][currency]
|
|
78
|
+
except KeyError:
|
|
79
|
+
with suppress(CurrencyFXRates.DoesNotExist):
|
|
80
|
+
fx_rate = CurrencyFXRates.objects.get(
|
|
81
|
+
currency=currency, date=val_date
|
|
82
|
+
) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
|
|
83
|
+
self._fx_rates[val_date][currency] = fx_rate
|
|
84
|
+
return fx_rate
|
|
85
|
+
|
|
86
|
+
def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
|
|
87
|
+
try:
|
|
88
|
+
return self.prices[val_date][instrument.id]
|
|
89
|
+
except KeyError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _dict_to_model(
|
|
93
|
+
self,
|
|
94
|
+
val_date: date,
|
|
95
|
+
instrument_id: int,
|
|
96
|
+
weighting: float,
|
|
97
|
+
infer_underlying_quote_price: bool = False,
|
|
98
|
+
**kwargs,
|
|
99
|
+
) -> "AssetPosition":
|
|
100
|
+
underlying_quote = self._get_instrument(instrument_id)
|
|
101
|
+
currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
|
|
102
|
+
currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
|
|
103
|
+
price = self._get_price(val_date, underlying_quote)
|
|
104
|
+
|
|
105
|
+
parameters = dict(
|
|
106
|
+
underlying_quote=underlying_quote,
|
|
107
|
+
weighting=round(weighting, 8),
|
|
108
|
+
date=val_date,
|
|
109
|
+
asset_valuation_date=val_date,
|
|
110
|
+
is_estimated=True,
|
|
111
|
+
portfolio=self.portfolio,
|
|
112
|
+
currency=underlying_quote.currency,
|
|
113
|
+
initial_price=price,
|
|
114
|
+
currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
|
|
115
|
+
currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
|
|
116
|
+
initial_currency_fx_rate=None,
|
|
117
|
+
underlying_quote_price=None,
|
|
118
|
+
underlying_instrument=None,
|
|
119
|
+
)
|
|
120
|
+
parameters.update(kwargs)
|
|
121
|
+
position = AssetPosition(**parameters)
|
|
122
|
+
position.pre_save(
|
|
123
|
+
infer_underlying_quote_price=infer_underlying_quote_price
|
|
124
|
+
) # inferring underlying quote price is potentially very slow for big dataset of positions, it's not very needed for model portfolio so we disable it
|
|
125
|
+
return position
|
|
126
|
+
|
|
127
|
+
def set_return(self, returns: pd.DataFrame):
|
|
128
|
+
self.returns = returns
|
|
129
|
+
|
|
130
|
+
def set_prices(self, prices: dict[date, dict[int, float]] | None = None):
|
|
131
|
+
self.prices = prices
|
|
132
|
+
|
|
133
|
+
def load_returns(self, instrument_ids: list[int], from_date: date, to_date: date):
|
|
134
|
+
self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
|
|
135
|
+
from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=True
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def add(
|
|
139
|
+
self,
|
|
140
|
+
positions: list["AssetPosition"] | tuple[date, dict[int, float]],
|
|
141
|
+
infer_underlying_quote_price: bool = False,
|
|
142
|
+
):
|
|
143
|
+
"""
|
|
144
|
+
Add multiple positions efficiently with batch processing
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
positions: Iterable of AssetPosition instances or dictionary of weight {instrument_id: weight} that needs to be converted into AssetPosition
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(positions, tuple):
|
|
150
|
+
val_date = positions[0]
|
|
151
|
+
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
152
|
+
for position in positions:
|
|
153
|
+
if not isinstance(position, AssetPosition):
|
|
154
|
+
position = self._dict_to_model(*position, infer_underlying_quote_price=infer_underlying_quote_price)
|
|
155
|
+
|
|
156
|
+
# Generate unique composite key
|
|
157
|
+
key = (
|
|
158
|
+
position.underlying_quote.id,
|
|
159
|
+
position.portfolio_created.id if position.portfolio_created else None,
|
|
160
|
+
)
|
|
161
|
+
# Merge duplicate positions
|
|
162
|
+
if existing_position := self._positions[position.date].get(key):
|
|
163
|
+
position.weighting += existing_position.weighting
|
|
164
|
+
if existing_position.initial_shares:
|
|
165
|
+
position.initial_shares += existing_position.initial_shares
|
|
166
|
+
# ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
|
|
167
|
+
position.portfolio = self.portfolio
|
|
168
|
+
if position.initial_price is not None and position.initial_currency_fx_rate is not None:
|
|
169
|
+
self._positions[position.date][key] = position
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def get_dates(self) -> list[date]:
|
|
173
|
+
"""Get sorted list of unique dates"""
|
|
174
|
+
return list(sorted(self._positions.keys()))
|
|
175
|
+
|
|
176
|
+
def _get_portfolio(self, val_date: date) -> AnalyticPortfolio:
|
|
177
|
+
"""Get weight structure with instrument IDs as keys"""
|
|
178
|
+
positions = self._positions[val_date]
|
|
179
|
+
next_returns = self.returns.loc[[(val_date + BDay(1)).date()], :]
|
|
180
|
+
weights = dict(map(lambda row: (row[1].underlying_quote.id, float(row[1].weighting)), positions.items()))
|
|
181
|
+
return AnalyticPortfolio(weights=weights, X=next_returns)
|
|
182
|
+
|
|
183
|
+
def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
|
|
184
|
+
positions = list(self.get_positions(**kwargs))
|
|
185
|
+
|
|
186
|
+
# we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
|
|
187
|
+
# overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
|
|
188
|
+
# change completely the trades of a portfolio model and drift it.
|
|
189
|
+
dates = self.get_dates()
|
|
190
|
+
self.portfolio.assets.filter(date__in=dates, is_estimated=True).delete()
|
|
191
|
+
|
|
192
|
+
if len(positions) > 0:
|
|
193
|
+
if self.portfolio.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
|
|
194
|
+
leftover_positions_ids = list(
|
|
195
|
+
self.portfolio.assets.filter(date__in=dates).values_list("id", flat=True)
|
|
196
|
+
) # we need to get the ids otherwise the queryset is reevaluated later
|
|
197
|
+
logger.info(f"bulk saving {len(positions)} positions ({len(leftover_positions_ids)} leftovers) ...")
|
|
198
|
+
objs = AssetPosition.unannotated_objects.bulk_create(
|
|
199
|
+
positions,
|
|
200
|
+
update_fields=[
|
|
201
|
+
"weighting",
|
|
202
|
+
"initial_price",
|
|
203
|
+
"initial_currency_fx_rate",
|
|
204
|
+
"initial_shares",
|
|
205
|
+
"currency_fx_rate_instrument_to_usd",
|
|
206
|
+
"currency_fx_rate_portfolio_to_usd",
|
|
207
|
+
"underlying_quote_price",
|
|
208
|
+
"portfolio",
|
|
209
|
+
"portfolio_created",
|
|
210
|
+
"underlying_instrument",
|
|
211
|
+
],
|
|
212
|
+
unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
|
|
213
|
+
update_conflicts=True,
|
|
214
|
+
batch_size=10000,
|
|
215
|
+
)
|
|
216
|
+
if delete_leftovers:
|
|
217
|
+
objs_ids = list(map(lambda x: x.id, objs))
|
|
218
|
+
leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
|
|
219
|
+
logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
|
|
220
|
+
AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
|
|
221
|
+
|
|
222
|
+
for val_date in self.get_dates():
|
|
223
|
+
if self.portfolio.is_tracked:
|
|
224
|
+
with suppress(KeyError):
|
|
225
|
+
changed_portfolio = self._get_portfolio(val_date)
|
|
226
|
+
self._change_at_date_tasks[val_date] = changed_portfolio
|
|
227
|
+
self._compute_metrics_tasks.add(val_date)
|
|
228
|
+
self._positions = defaultdict(dict)
|
|
229
|
+
|
|
230
|
+
def schedule_metric_computation(self):
|
|
231
|
+
if self._compute_metrics_tasks:
|
|
232
|
+
basket_id = self.portfolio.id
|
|
233
|
+
basket_content_type_id = ContentType.objects.get_by_natural_key("wbportfolio", "portfolio").id
|
|
234
|
+
group(
|
|
235
|
+
*[
|
|
236
|
+
compute_metrics_as_task.si(d, basket_id=basket_id, basket_content_type_id=basket_content_type_id)
|
|
237
|
+
for d in self._compute_metrics_tasks
|
|
238
|
+
]
|
|
239
|
+
).apply_async()
|
|
240
|
+
self._change_at_date_tasks = set()
|
|
241
|
+
|
|
242
|
+
def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
|
|
243
|
+
from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
|
|
244
|
+
|
|
245
|
+
if self._change_at_date_tasks:
|
|
246
|
+
tasks = chain(
|
|
247
|
+
*[
|
|
248
|
+
trigger_portfolio_change_as_task.si(
|
|
249
|
+
self.portfolio.id, d, changed_portfolio=portfolio, **task_kwargs
|
|
250
|
+
)
|
|
251
|
+
for d, portfolio in self._change_at_date_tasks.items()
|
|
252
|
+
]
|
|
253
|
+
)
|
|
254
|
+
if synchronous:
|
|
255
|
+
tasks.apply()
|
|
256
|
+
else:
|
|
257
|
+
tasks.apply_async()
|
|
258
|
+
self._change_at_date_tasks = dict()
|
|
@@ -33,8 +33,7 @@ from wbcore.utils.models import CloneMixin
|
|
|
33
33
|
from wbfdm.models import InstrumentPrice
|
|
34
34
|
from wbfdm.models.instruments.instruments import Cash, Instrument
|
|
35
35
|
|
|
36
|
-
from wbportfolio.models.asset import AssetPosition
|
|
37
|
-
from wbportfolio.models.exceptions import InvalidAnalyticPortfolio
|
|
36
|
+
from wbportfolio.models.asset import AssetPosition
|
|
38
37
|
from wbportfolio.models.roles import PortfolioRole
|
|
39
38
|
from wbportfolio.pms.trading import TradingService
|
|
40
39
|
from wbportfolio.pms.typing import Portfolio as PortfolioDTO
|
|
@@ -79,6 +78,9 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
79
78
|
on_delete=models.PROTECT,
|
|
80
79
|
verbose_name="Owner",
|
|
81
80
|
)
|
|
81
|
+
min_order_value = models.IntegerField(
|
|
82
|
+
default=0, verbose_name="Minimum Order Value", help_text="Minimum Order Value in the Portfolio currency"
|
|
83
|
+
)
|
|
82
84
|
|
|
83
85
|
class Meta:
|
|
84
86
|
verbose_name = "Order Proposal"
|
|
@@ -218,6 +220,12 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
218
220
|
),
|
|
219
221
|
default=models.F("effective_weight"),
|
|
220
222
|
),
|
|
223
|
+
target_weight=models.Case(
|
|
224
|
+
models.When(
|
|
225
|
+
id=largest_order.id, then=models.F("target_weight") + models.Value(Decimal(quant_error))
|
|
226
|
+
),
|
|
227
|
+
default=models.F("target_weight"),
|
|
228
|
+
),
|
|
221
229
|
)
|
|
222
230
|
return orders.annotate(
|
|
223
231
|
has_warnings=models.Case(
|
|
@@ -257,7 +265,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
257
265
|
self.value_date, weights=previous_weights, use_dl=True
|
|
258
266
|
).get_contributions()
|
|
259
267
|
last_returns = last_returns.to_dict()
|
|
260
|
-
except
|
|
268
|
+
except ValueError:
|
|
261
269
|
last_returns, portfolio_contribution = {}, 1
|
|
262
270
|
positions = []
|
|
263
271
|
total_weighting = Decimal("0")
|
|
@@ -456,14 +464,20 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
456
464
|
if approve_automatically and self.portfolio.can_be_rebalanced:
|
|
457
465
|
self.approve(replay=False)
|
|
458
466
|
|
|
459
|
-
def replay(self, broadcast_changes_at_date: bool = True):
|
|
467
|
+
def replay(self, broadcast_changes_at_date: bool = True, reapply_order_proposal: bool = False):
|
|
460
468
|
last_order_proposal = self
|
|
461
469
|
last_order_proposal_created = False
|
|
470
|
+
self.portfolio.load_builder_returns(self.trade_date, date.today())
|
|
462
471
|
while last_order_proposal and last_order_proposal.status == OrderProposal.Status.APPROVED:
|
|
463
|
-
|
|
472
|
+
last_order_proposal.portfolio = self.portfolio # we set the same ptf reference
|
|
464
473
|
if not last_order_proposal_created:
|
|
465
|
-
|
|
466
|
-
|
|
474
|
+
if reapply_order_proposal:
|
|
475
|
+
logger.info(f"Replaying order proposal {last_order_proposal}")
|
|
476
|
+
last_order_proposal.approve_workflow(silent_exception=True, force_reset_order=True)
|
|
477
|
+
last_order_proposal.save()
|
|
478
|
+
else:
|
|
479
|
+
logger.info(f"Resetting order proposal {last_order_proposal}")
|
|
480
|
+
last_order_proposal.reset_orders()
|
|
467
481
|
if last_order_proposal.status != OrderProposal.Status.APPROVED:
|
|
468
482
|
break
|
|
469
483
|
next_order_proposal = last_order_proposal.next_order_proposal
|
|
@@ -478,21 +492,17 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
478
492
|
else:
|
|
479
493
|
next_trade_date = date.today()
|
|
480
494
|
next_trade_date = min(next_trade_date, date.today())
|
|
481
|
-
|
|
495
|
+
gen = self.portfolio.drift_weights(
|
|
482
496
|
last_order_proposal.trade_date, next_trade_date, stop_at_rebalancing=True
|
|
483
497
|
)
|
|
498
|
+
try:
|
|
499
|
+
while True:
|
|
500
|
+
self.portfolio.builder.add(next(gen))
|
|
501
|
+
except StopIteration as e:
|
|
502
|
+
overriding_order_proposal = e.value
|
|
484
503
|
|
|
485
|
-
|
|
486
|
-
# date__gt=last_order_proposal.trade_date, date__lte=next_trade_date, is_estimated=False
|
|
487
|
-
# ).update(
|
|
488
|
-
# is_estimated=True
|
|
489
|
-
# ) # ensure that we reset non estimated position leftover to estimated between order proposal during replay
|
|
490
|
-
self.portfolio.bulk_create_positions(
|
|
491
|
-
positions,
|
|
504
|
+
self.portfolio.builder.bulk_create_positions(
|
|
492
505
|
delete_leftovers=True,
|
|
493
|
-
compute_metrics=False,
|
|
494
|
-
broadcast_changes_at_date=broadcast_changes_at_date,
|
|
495
|
-
evaluate_rebalancer=False,
|
|
496
506
|
)
|
|
497
507
|
for draft_tp in OrderProposal.objects.filter(
|
|
498
508
|
portfolio=self.portfolio,
|
|
@@ -507,6 +517,8 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
507
517
|
else:
|
|
508
518
|
last_order_proposal_created = False
|
|
509
519
|
last_order_proposal = next_order_proposal
|
|
520
|
+
if broadcast_changes_at_date:
|
|
521
|
+
self.portfolio.builder.schedule_change_at_dates(synchronous=False, evaluate_rebalancer=False)
|
|
510
522
|
|
|
511
523
|
def invalidate_future_order_proposal(self):
|
|
512
524
|
# Delete all future automatic order proposals and set the manual one into a draft state
|
|
@@ -576,7 +588,6 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
576
588
|
total_target_weight = orders.exclude(underlying_instrument__is_cash=True).aggregate(
|
|
577
589
|
s=models.Sum("target_weight")
|
|
578
590
|
)["s"] or Decimal(0)
|
|
579
|
-
|
|
580
591
|
if target_cash_weight is None:
|
|
581
592
|
target_cash_weight = Decimal("1") - total_target_weight
|
|
582
593
|
|
|
@@ -644,7 +655,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
644
655
|
orders_validation_warnings.extend(order_warnings)
|
|
645
656
|
orders.append(order)
|
|
646
657
|
|
|
647
|
-
Order.objects.bulk_update(orders, ["shares", "weighting"])
|
|
658
|
+
Order.objects.bulk_update(orders, ["shares", "weighting", "desired_target_weight"])
|
|
648
659
|
|
|
649
660
|
# If we estimate cash on this order proposal, we make sure to create the corresponding cash component
|
|
650
661
|
estimated_cash_position = self.get_estimated_target_cash()
|
|
@@ -704,30 +715,25 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
704
715
|
# We validate order which will create or update the initial asset positions
|
|
705
716
|
if not self.portfolio.can_be_rebalanced:
|
|
706
717
|
raise ValueError("Non-Rebalanceable portfolio cannot be traded manually.")
|
|
707
|
-
assets = []
|
|
708
718
|
warnings = []
|
|
709
719
|
# We do not want to create the estimated cash position if there is not orders in the order proposal (shouldn't be possible anyway)
|
|
710
720
|
estimated_cash_position = self.get_estimated_target_cash()
|
|
721
|
+
assets = {}
|
|
711
722
|
for order in self.get_orders():
|
|
712
723
|
with suppress(ValueError):
|
|
713
|
-
asset = order.get_asset()
|
|
714
724
|
# we add the corresponding asset only if it is not the cache position (already included in estimated_cash_position)
|
|
715
|
-
if
|
|
716
|
-
assets.
|
|
725
|
+
if order.underlying_instrument != estimated_cash_position.underlying_quote:
|
|
726
|
+
assets[order.underlying_instrument.id] = order._target_weight
|
|
717
727
|
|
|
718
728
|
# if there is cash leftover, we create an extra asset position to hold the cash component
|
|
719
729
|
if estimated_cash_position.weighting and len(assets) > 0:
|
|
720
730
|
warnings.append(
|
|
721
731
|
f"We created automatically a cash position of weight {estimated_cash_position.weighting:.2%}"
|
|
722
732
|
)
|
|
723
|
-
estimated_cash_position.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
AssetPositionIterator(self.portfolio).add(assets, is_estimated=False),
|
|
728
|
-
evaluate_rebalancer=False,
|
|
729
|
-
force_save=True,
|
|
730
|
-
**kwargs,
|
|
733
|
+
assets[estimated_cash_position.underlying_quote.id] = estimated_cash_position.weighting
|
|
734
|
+
|
|
735
|
+
self.portfolio.builder.add((self.trade_date, assets)).bulk_create_positions(
|
|
736
|
+
force_save=True, is_estimated=False
|
|
731
737
|
)
|
|
732
738
|
if replay and self.portfolio.is_manageable:
|
|
733
739
|
replay_as_task.delay(self.id, user_id=by.id if by else None, broadcast_changes_at_date=False)
|
|
@@ -748,7 +754,7 @@ class OrderProposal(CloneMixin, RiskCheckMixin, WBModel):
|
|
|
748
754
|
]
|
|
749
755
|
if self.has_non_successful_checks:
|
|
750
756
|
errors["non_field_errors"] = [_("The pre orders rules did not passed successfully")]
|
|
751
|
-
if orders.filter(has_warnings=True):
|
|
757
|
+
if orders.filter(has_warnings=True).exclude(underlying_instrument__is_cash=True):
|
|
752
758
|
errors["non_field_errors"] = [
|
|
753
759
|
_("There is warning that needs to be addresses on the orders before approval.")
|
|
754
760
|
]
|