wbportfolio 1.49.8__py2.py3-none-any.whl → 1.49.10__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.

@@ -7,7 +7,7 @@ FIELD_MAP = {
7
7
  "Date": "transaction_date",
8
8
  "Manag. Fees Natixis": "ISSUER",
9
9
  "Manag. Fees Client": "MANAGEMENT",
10
- "Perf fees amount": "PERFORMANCE"
10
+ "Perf fees amount": "PERFORMANCE",
11
11
  # "Currency": "currency"
12
12
  }
13
13
 
@@ -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="'", usecols=[1, 3, 4, 7, 10, 15, 16, 17, 18, 20, 21])
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 and then remove both cols
58
+ # Merge register_firstname and cust_ref
43
59
  df["note"] = df["register_firstname"] + df["cust_ref"]
44
- del df["register_firstname"]
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 {
@@ -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,149 @@ 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 = 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
+
125
+ position = AssetPosition(
126
+ underlying_quote=underlying_quote,
127
+ weighting=weighting,
128
+ date=val_date,
129
+ asset_valuation_date=val_date,
130
+ is_estimated=True,
131
+ portfolio=self.portfolio,
132
+ currency=underlying_quote.currency,
133
+ initial_price=price,
134
+ currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
135
+ currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
136
+ initial_currency_fx_rate=None,
137
+ underlying_quote_price=None,
138
+ underlying_instrument=None,
139
+ )
140
+ position.pre_save(
141
+ infer_underlying_quote_price=self.infer_underlying_quote_price
142
+ ) # 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
143
+ return position
144
+
145
+ def add(
146
+ self,
147
+ positions: list["AssetPosition"] | tuple[date, dict[int, float]],
148
+ ):
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)
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
+ position.initial_shares += existing_position.initial_shares
172
+ # ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
173
+ position.portfolio = self.portfolio
174
+ if position.initial_price is not None:
175
+ self.positions[key] = position
176
+ try:
177
+ self._weights[position.date][position.underlying_quote.id] += float(position.weighting)
178
+ except KeyError:
179
+ self._weights[position.date] = {position.underlying_quote.id: float(position.weighting)}
180
+ return self
181
+
182
+ def get_dates(self) -> list[date]:
183
+ """Get sorted list of unique dates"""
184
+ return list(self._weights.keys())
185
+
186
+ def get_weights(self) -> dict[date, dict[int, float]]:
187
+ """Get weight structure with instrument IDs as keys"""
188
+ return self._weights
189
+
190
+ def __iter__(self):
191
+ # return an iterable excluding the position without a valid weighting
192
+ yield from filter(lambda a: a.weighting, self.positions.values())
193
+
194
+ def __getitem__(self, item: tuple[date, Instrument]) -> float:
195
+ return self._weights[item[0]][item[1].id]
196
+
197
+ def __bool__(self) -> bool:
198
+ return len(self.positions.keys()) > 0
199
+
54
200
 
55
201
  class AssetPositionDefaultQueryset(QuerySet):
56
202
  def annotate_classification_for_group(
@@ -498,6 +644,17 @@ class AssetPosition(ImportMixin, models.Model):
498
644
  except InvalidOperation:
499
645
  self.initial_currency_fx_rate = Decimal(0.0)
500
646
 
647
+ # we set the initial shares from the previous position shares number if portfolio allows it
648
+ if self.initial_shares is None and not self.portfolio.only_weighting:
649
+ with suppress(AssetPosition.DoesNotExist):
650
+ previous_pos = AssetPosition.objects.get(
651
+ date=(self.date - BDay(1)).date(),
652
+ underlying_quote=self.underlying_quote,
653
+ portfolio=self.portfolio,
654
+ portfolio_created=self.portfolio_created,
655
+ )
656
+ self.initial_shares = previous_pos.initial_shares
657
+
501
658
  def save(self, *args, create_underlying_quote_price_if_missing: bool = False, **kwargs):
502
659
  self.pre_save(create_underlying_quote_price_if_missing=create_underlying_quote_price_if_missing)
503
660
  super().save(*args, **kwargs)