wbportfolio 1.49.9__py2.py3-none-any.whl → 1.49.11__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/import_export/parsers/natixis/fees.py +1 -1
- wbportfolio/import_export/parsers/sg_lux/customer_trade_slk.py +31 -17
- wbportfolio/models/asset.py +154 -0
- wbportfolio/models/portfolio.py +205 -255
- wbportfolio/models/portfolio_cash_flow.py +5 -5
- wbportfolio/models/roles.py +1 -5
- wbportfolio/models/transactions/trade_proposals.py +18 -4
- wbportfolio/models/transactions/trades.py +1 -1
- wbportfolio/tests/models/test_portfolios.py +50 -65
- wbportfolio/tests/models/test_products.py +0 -5
- wbportfolio/tests/models/transactions/test_trade_proposals.py +17 -5
- wbportfolio/viewsets/portfolios.py +2 -2
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/METADATA +1 -1
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/RECORD +16 -16
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/WHEEL +0 -0
- {wbportfolio-1.49.9.dist-info → wbportfolio-1.49.11.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,10 +10,27 @@ from wbportfolio.models import Product, Trade
|
|
|
10
10
|
from .sylk import SYLK
|
|
11
11
|
from .utils import get_portfolio_id
|
|
12
12
|
|
|
13
|
+
MAP = {
|
|
14
|
+
"register_id": "register__register_reference",
|
|
15
|
+
"register_name": "bank",
|
|
16
|
+
"isin": "underlying_instrument",
|
|
17
|
+
"trans_date": "transaction_date",
|
|
18
|
+
"settl_date": "value_date",
|
|
19
|
+
"trans_ref": "external_id",
|
|
20
|
+
"trans_price": "price",
|
|
21
|
+
"quantity": "shares",
|
|
22
|
+
"note": "comment",
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
|
|
14
26
|
def parse(import_source):
|
|
15
27
|
data = list()
|
|
16
28
|
sylk_handler = SYLK()
|
|
29
|
+
merger_isin_map = import_source.source.import_parameters.get("custom_merged_isin_map", {"XX": "LU"})
|
|
30
|
+
merger_export_parts_trans_desc = import_source.source.import_parameters.get(
|
|
31
|
+
"merger_export_parts_trans_desc", "Export Parts"
|
|
32
|
+
)
|
|
33
|
+
|
|
17
34
|
for line in [_line.decode("cp1252") for _line in import_source.file.open("rb").readlines()]:
|
|
18
35
|
sylk_handler.parseline(line)
|
|
19
36
|
|
|
@@ -24,11 +41,10 @@ def parse(import_source):
|
|
|
24
41
|
|
|
25
42
|
buffer.seek(0)
|
|
26
43
|
content = buffer.read().replace('""', "")
|
|
27
|
-
df = pd.read_csv(StringIO(content), sep=";", quotechar="'"
|
|
44
|
+
df = pd.read_csv(StringIO(content), sep=";", quotechar="'")
|
|
28
45
|
if not df.empty:
|
|
29
46
|
# Filter out all non transaction rows and remove record_desc col
|
|
30
47
|
df = df[df["record_desc"].str.strip() == "TRANSACTION"]
|
|
31
|
-
del df["record_desc"]
|
|
32
48
|
|
|
33
49
|
# Convert timestamps to json conform date strings
|
|
34
50
|
df["trans_date"] = df["trans_date"].apply(lambda x: xldate_as_datetime(x, datemode=0).date())
|
|
@@ -39,9 +55,17 @@ def parse(import_source):
|
|
|
39
55
|
# Replace all nan values with empty str
|
|
40
56
|
df[["register_firstname", "cust_ref"]] = df[["register_firstname", "cust_ref"]].replace(np.nan, "", regex=True)
|
|
41
57
|
|
|
42
|
-
# Merge register_firstname and cust_ref
|
|
58
|
+
# Merge register_firstname and cust_ref
|
|
43
59
|
df["note"] = df["register_firstname"] + df["cust_ref"]
|
|
44
|
-
|
|
60
|
+
# the trade marked with "Export Parts" and where isin is marked for merge need to be filter out as they are introduced by the merger
|
|
61
|
+
df = df[
|
|
62
|
+
~(
|
|
63
|
+
(df["trans_desc"] == merger_export_parts_trans_desc)
|
|
64
|
+
& (df["isin"].str.contains("|".join(merger_isin_map.keys())))
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
for pat, repl in merger_isin_map.items():
|
|
68
|
+
df["isin"] = df["isin"].str.replace(pat, repl)
|
|
45
69
|
|
|
46
70
|
# Create Product Mapping and apply to df
|
|
47
71
|
product_mapping = {
|
|
@@ -50,21 +74,10 @@ def parse(import_source):
|
|
|
50
74
|
}
|
|
51
75
|
df["isin"] = df["isin"].apply(lambda x: product_mapping[x])
|
|
52
76
|
|
|
53
|
-
del df["cust_ref"]
|
|
54
|
-
|
|
55
77
|
# Rename Columns
|
|
78
|
+
df = df.rename(columns=MAP)
|
|
79
|
+
df = df[df.columns.intersection(MAP.values())]
|
|
56
80
|
|
|
57
|
-
df.columns = [
|
|
58
|
-
"register__register_reference",
|
|
59
|
-
"bank",
|
|
60
|
-
"underlying_instrument",
|
|
61
|
-
"transaction_date",
|
|
62
|
-
"value_date",
|
|
63
|
-
"external_id",
|
|
64
|
-
"price",
|
|
65
|
-
"shares",
|
|
66
|
-
"comment",
|
|
67
|
-
]
|
|
68
81
|
df["transaction_subtype"] = df.shares.apply(
|
|
69
82
|
lambda x: Trade.Type.REDEMPTION.value if x < 0 else Trade.Type.SUBSCRIPTION.value
|
|
70
83
|
)
|
|
@@ -74,6 +87,7 @@ def parse(import_source):
|
|
|
74
87
|
)
|
|
75
88
|
df["pending"] = False
|
|
76
89
|
# Convert df to list of dicts
|
|
90
|
+
|
|
77
91
|
data = df.to_dict("records")
|
|
78
92
|
|
|
79
93
|
return {
|
wbportfolio/models/asset.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
1
2
|
from contextlib import suppress
|
|
2
3
|
from datetime import date
|
|
3
4
|
from decimal import Decimal, InvalidOperation
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
4
6
|
|
|
5
7
|
from django.contrib import admin
|
|
6
8
|
from django.db import models
|
|
@@ -24,6 +26,7 @@ from django.db.models.functions import Coalesce
|
|
|
24
26
|
from django.db.models.signals import post_save
|
|
25
27
|
from django.dispatch import receiver
|
|
26
28
|
from django.utils.functional import cached_property
|
|
29
|
+
from pandas._libs.tslibs.offsets import BDay
|
|
27
30
|
from wbcore.contrib.currency.models import Currency, CurrencyFXRates
|
|
28
31
|
from wbcore.contrib.io.mixins import ImportMixin
|
|
29
32
|
from wbcore.signals import pre_merge
|
|
@@ -51,6 +54,146 @@ MINUTE = 60
|
|
|
51
54
|
HOUR = MINUTE * 60
|
|
52
55
|
DAY = HOUR * 24
|
|
53
56
|
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from wbportfolio.models.portfolio import Portfolio
|
|
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:
|
|
104
|
+
try:
|
|
105
|
+
return self._fx_rates[val_date][currency]
|
|
106
|
+
except KeyError:
|
|
107
|
+
fx_rate = CurrencyFXRates.objects.get_or_create(
|
|
108
|
+
currency=currency, date=val_date, defaults={"value": Decimal(0)}
|
|
109
|
+
)[0] # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
|
|
110
|
+
self._fx_rates[val_date][currency] = fx_rate
|
|
111
|
+
return fx_rate
|
|
112
|
+
|
|
113
|
+
def _get_price(self, val_date: date, instrument: Instrument) -> float | None:
|
|
114
|
+
try:
|
|
115
|
+
return self._prices[val_date][instrument.id]
|
|
116
|
+
except KeyError:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def _dict_to_model(self, val_date: date, instrument_id: int, weighting: float) -> "AssetPosition":
|
|
120
|
+
underlying_quote = self._get_instrument(instrument_id)
|
|
121
|
+
currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
|
|
122
|
+
currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
|
|
123
|
+
price = self._get_price(val_date, underlying_quote)
|
|
124
|
+
position = AssetPosition(
|
|
125
|
+
underlying_quote=underlying_quote,
|
|
126
|
+
weighting=weighting,
|
|
127
|
+
date=val_date,
|
|
128
|
+
asset_valuation_date=val_date,
|
|
129
|
+
is_estimated=True,
|
|
130
|
+
portfolio=self.portfolio,
|
|
131
|
+
currency=underlying_quote.currency,
|
|
132
|
+
initial_price=price,
|
|
133
|
+
currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
|
|
134
|
+
currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
|
|
135
|
+
initial_currency_fx_rate=None,
|
|
136
|
+
underlying_quote_price=None,
|
|
137
|
+
underlying_instrument=None,
|
|
138
|
+
)
|
|
139
|
+
position.pre_save(
|
|
140
|
+
infer_underlying_quote_price=self.infer_underlying_quote_price
|
|
141
|
+
) # 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
|
|
142
|
+
return position
|
|
143
|
+
|
|
144
|
+
def add(
|
|
145
|
+
self,
|
|
146
|
+
positions: list["AssetPosition"] | tuple[date, dict[int, float]],
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
Add multiple positions efficiently with batch processing
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
positions: Iterable of AssetPosition instances or dictionary of weight {instrument_id: weight} that needs to be converted into AssetPosition
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(positions, tuple):
|
|
155
|
+
val_date = positions[0]
|
|
156
|
+
positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
|
|
157
|
+
for position in positions:
|
|
158
|
+
if not isinstance(position, AssetPosition):
|
|
159
|
+
position = self._dict_to_model(*position)
|
|
160
|
+
|
|
161
|
+
# Generate unique composite key
|
|
162
|
+
key = (
|
|
163
|
+
position.date,
|
|
164
|
+
position.underlying_quote.id,
|
|
165
|
+
position.portfolio_created.id if position.portfolio_created else None,
|
|
166
|
+
)
|
|
167
|
+
# Merge duplicate positions
|
|
168
|
+
if existing_position := self.positions.get(key):
|
|
169
|
+
position.weighting += existing_position.weighting
|
|
170
|
+
position.initial_shares += existing_position.initial_shares
|
|
171
|
+
# ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
|
|
172
|
+
position.portfolio = self.portfolio
|
|
173
|
+
if position.initial_price is not None:
|
|
174
|
+
self.positions[key] = position
|
|
175
|
+
self._weights[position.date][position.underlying_quote.id] = float(position.weighting)
|
|
176
|
+
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def get_dates(self) -> list[date]:
|
|
180
|
+
"""Get sorted list of unique dates"""
|
|
181
|
+
return list(self._weights.keys())
|
|
182
|
+
|
|
183
|
+
def get_weights(self) -> dict[date, dict[int, float]]:
|
|
184
|
+
"""Get weight structure with instrument IDs as keys"""
|
|
185
|
+
return dict(self._weights)
|
|
186
|
+
|
|
187
|
+
def __iter__(self):
|
|
188
|
+
# return an iterable excluding the position without a valid weighting
|
|
189
|
+
yield from filter(lambda a: a.weighting, self.positions.values())
|
|
190
|
+
|
|
191
|
+
def __getitem__(self, item: tuple[date, Instrument]) -> float:
|
|
192
|
+
return self._weights[item[0]][item[1].id]
|
|
193
|
+
|
|
194
|
+
def __bool__(self) -> bool:
|
|
195
|
+
return len(self.positions.keys()) > 0
|
|
196
|
+
|
|
54
197
|
|
|
55
198
|
class AssetPositionDefaultQueryset(QuerySet):
|
|
56
199
|
def annotate_classification_for_group(
|
|
@@ -498,6 +641,17 @@ class AssetPosition(ImportMixin, models.Model):
|
|
|
498
641
|
except InvalidOperation:
|
|
499
642
|
self.initial_currency_fx_rate = Decimal(0.0)
|
|
500
643
|
|
|
644
|
+
# we set the initial shares from the previous position shares number if portfolio allows it
|
|
645
|
+
if self.initial_shares is None and not self.portfolio.only_weighting:
|
|
646
|
+
with suppress(AssetPosition.DoesNotExist):
|
|
647
|
+
previous_pos = AssetPosition.objects.get(
|
|
648
|
+
date=(self.date - BDay(1)).date(),
|
|
649
|
+
underlying_quote=self.underlying_quote,
|
|
650
|
+
portfolio=self.portfolio,
|
|
651
|
+
portfolio_created=self.portfolio_created,
|
|
652
|
+
)
|
|
653
|
+
self.initial_shares = previous_pos.initial_shares
|
|
654
|
+
|
|
501
655
|
def save(self, *args, create_underlying_quote_price_if_missing: bool = False, **kwargs):
|
|
502
656
|
self.pre_save(create_underlying_quote_price_if_missing=create_underlying_quote_price_if_missing)
|
|
503
657
|
super().save(*args, **kwargs)
|