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

Files changed (273) hide show
  1. wbportfolio/admin/__init__.py +3 -1
  2. wbportfolio/admin/indexes.py +1 -1
  3. wbportfolio/admin/orders/__init__.py +2 -0
  4. wbportfolio/admin/orders/order_proposals.py +16 -0
  5. wbportfolio/admin/orders/orders.py +32 -0
  6. wbportfolio/admin/portfolio.py +11 -5
  7. wbportfolio/admin/product_groups.py +1 -1
  8. wbportfolio/admin/products.py +2 -1
  9. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  10. wbportfolio/admin/transactions/__init__.py +0 -2
  11. wbportfolio/admin/transactions/dividends.py +40 -4
  12. wbportfolio/admin/transactions/fees.py +24 -14
  13. wbportfolio/admin/transactions/trades.py +34 -27
  14. wbportfolio/analysis/claims.py +5 -6
  15. wbportfolio/api_clients/ubs.py +162 -0
  16. wbportfolio/constants.py +1 -0
  17. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  18. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  19. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  20. wbportfolio/contrib/company_portfolio/models.py +69 -39
  21. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  22. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  23. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  24. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  25. wbportfolio/defaults/fees/default.py +7 -15
  26. wbportfolio/factories/__init__.py +2 -2
  27. wbportfolio/factories/assets.py +1 -1
  28. wbportfolio/factories/dividends.py +8 -3
  29. wbportfolio/factories/fees.py +8 -4
  30. wbportfolio/factories/orders/__init__.py +2 -0
  31. wbportfolio/factories/orders/order_proposals.py +21 -0
  32. wbportfolio/factories/orders/orders.py +34 -0
  33. wbportfolio/factories/portfolios.py +2 -1
  34. wbportfolio/factories/product_groups.py +3 -3
  35. wbportfolio/factories/products.py +3 -3
  36. wbportfolio/factories/rebalancing.py +1 -1
  37. wbportfolio/factories/trades.py +12 -16
  38. wbportfolio/filters/assets.py +18 -4
  39. wbportfolio/filters/orders/__init__.py +2 -0
  40. wbportfolio/filters/orders/order_proposals.py +55 -0
  41. wbportfolio/filters/orders/orders.py +11 -0
  42. wbportfolio/filters/portfolios.py +38 -1
  43. wbportfolio/filters/positions.py +0 -1
  44. wbportfolio/filters/transactions/__init__.py +1 -2
  45. wbportfolio/filters/transactions/fees.py +5 -12
  46. wbportfolio/filters/transactions/trades.py +16 -8
  47. wbportfolio/filters/transactions/utils.py +42 -0
  48. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  49. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  50. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  51. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  52. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  53. wbportfolio/import_export/backends/utils.py +0 -17
  54. wbportfolio/import_export/handlers/asset_position.py +22 -10
  55. wbportfolio/import_export/handlers/dividend.py +8 -8
  56. wbportfolio/import_export/handlers/fees.py +13 -23
  57. wbportfolio/import_export/handlers/orders.py +71 -0
  58. wbportfolio/import_export/handlers/trade.py +53 -77
  59. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  60. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  61. wbportfolio/import_export/parsers/jpmorgan/fees.py +4 -4
  62. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  63. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  64. wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
  65. wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
  66. wbportfolio/import_export/parsers/leonteq/trade.py +2 -6
  67. wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
  68. wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
  69. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  70. wbportfolio/import_export/parsers/natixis/fees.py +7 -9
  71. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  72. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
  73. wbportfolio/import_export/parsers/sg_lux/equity.py +10 -10
  74. wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
  75. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
  76. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  77. wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
  78. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  79. wbportfolio/import_export/parsers/societe_generale/strategy.py +5 -5
  80. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  81. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  82. wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
  83. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  84. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  85. wbportfolio/import_export/parsers/ubs/equity.py +3 -2
  86. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  87. wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
  88. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
  89. wbportfolio/import_export/parsers/vontobel/management_fees.py +12 -20
  90. wbportfolio/import_export/parsers/vontobel/performance_fees.py +5 -8
  91. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  92. wbportfolio/import_export/resources/trades.py +3 -3
  93. wbportfolio/import_export/utils.py +3 -1
  94. wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
  95. wbportfolio/metric/backends/base.py +2 -2
  96. wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
  97. wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
  98. wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
  99. wbportfolio/migrations/0079_alter_trade_drift_factor.py +19 -0
  100. wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
  101. wbportfolio/migrations/0081_alter_trade_drift_factor.py +19 -0
  102. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  103. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  104. wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
  105. wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
  106. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  107. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  108. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  109. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  110. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  111. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  112. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  113. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  114. wbportfolio/models/__init__.py +2 -0
  115. wbportfolio/models/adjustments.py +1 -1
  116. wbportfolio/models/asset.py +28 -170
  117. wbportfolio/models/builder.py +323 -0
  118. wbportfolio/models/custodians.py +3 -3
  119. wbportfolio/models/exceptions.py +1 -1
  120. wbportfolio/models/graphs/portfolio.py +1 -1
  121. wbportfolio/models/graphs/utils.py +11 -11
  122. wbportfolio/models/mixins/instruments.py +7 -0
  123. wbportfolio/models/mixins/liquidity_stress_test.py +4 -4
  124. wbportfolio/models/orders/__init__.py +2 -0
  125. wbportfolio/models/orders/order_proposals.py +1414 -0
  126. wbportfolio/models/orders/orders.py +410 -0
  127. wbportfolio/models/portfolio.py +311 -289
  128. wbportfolio/models/portfolio_relationship.py +6 -0
  129. wbportfolio/models/products.py +12 -0
  130. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +40 -27
  131. wbportfolio/models/roles.py +4 -10
  132. wbportfolio/models/transactions/__init__.py +0 -4
  133. wbportfolio/models/transactions/claim.py +7 -6
  134. wbportfolio/models/transactions/dividends.py +42 -5
  135. wbportfolio/models/transactions/fees.py +55 -22
  136. wbportfolio/models/transactions/trades.py +121 -442
  137. wbportfolio/models/transactions/transactions.py +78 -158
  138. wbportfolio/models/utils.py +100 -1
  139. wbportfolio/order_routing/__init__.py +35 -0
  140. wbportfolio/order_routing/adapters/__init__.py +65 -0
  141. wbportfolio/order_routing/adapters/ubs.py +195 -0
  142. wbportfolio/order_routing/router.py +33 -0
  143. wbportfolio/order_routing/tests/__init__.py +0 -0
  144. wbportfolio/order_routing/tests/test_router.py +110 -0
  145. wbportfolio/permissions.py +7 -0
  146. wbportfolio/pms/analytics/portfolio.py +17 -9
  147. wbportfolio/pms/analytics/utils.py +9 -0
  148. wbportfolio/pms/trading/__init__.py +0 -1
  149. wbportfolio/pms/trading/optimizer.py +61 -0
  150. wbportfolio/pms/typing.py +198 -63
  151. wbportfolio/rebalancing/base.py +12 -1
  152. wbportfolio/rebalancing/decorators.py +1 -1
  153. wbportfolio/rebalancing/models/composite.py +4 -8
  154. wbportfolio/rebalancing/models/equally_weighted.py +13 -11
  155. wbportfolio/rebalancing/models/market_capitalization_weighted.py +21 -14
  156. wbportfolio/rebalancing/models/model_portfolio.py +14 -18
  157. wbportfolio/risk_management/backends/__init__.py +1 -0
  158. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  159. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  160. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  161. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  162. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  163. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  164. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  165. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  166. wbportfolio/serializers/__init__.py +1 -0
  167. wbportfolio/serializers/orders/__init__.py +2 -0
  168. wbportfolio/serializers/orders/order_proposals.py +115 -0
  169. wbportfolio/serializers/orders/orders.py +283 -0
  170. wbportfolio/serializers/portfolios.py +7 -7
  171. wbportfolio/serializers/positions.py +2 -2
  172. wbportfolio/serializers/rebalancing.py +1 -1
  173. wbportfolio/serializers/signals.py +9 -12
  174. wbportfolio/serializers/transactions/__init__.py +1 -10
  175. wbportfolio/serializers/transactions/claim.py +2 -2
  176. wbportfolio/serializers/transactions/dividends.py +37 -9
  177. wbportfolio/serializers/transactions/fees.py +39 -10
  178. wbportfolio/serializers/transactions/trades.py +55 -157
  179. wbportfolio/tasks.py +43 -5
  180. wbportfolio/tests/analysis/__init__.py +0 -0
  181. wbportfolio/tests/analysis/test_claims.py +85 -0
  182. wbportfolio/tests/conftest.py +12 -12
  183. wbportfolio/tests/models/orders/__init__.py +0 -0
  184. wbportfolio/tests/models/orders/test_order_proposals.py +1046 -0
  185. wbportfolio/tests/models/test_assets.py +7 -3
  186. wbportfolio/tests/models/test_imports.py +9 -13
  187. wbportfolio/tests/models/test_portfolios.py +102 -95
  188. wbportfolio/tests/models/test_products.py +11 -0
  189. wbportfolio/tests/models/test_splits.py +1 -6
  190. wbportfolio/tests/models/test_utils.py +140 -0
  191. wbportfolio/tests/models/transactions/test_fees.py +7 -13
  192. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  193. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  194. wbportfolio/tests/pms/test_analytics.py +22 -3
  195. wbportfolio/tests/rebalancing/test_models.py +51 -57
  196. wbportfolio/tests/signals.py +10 -20
  197. wbportfolio/tests/tests.py +3 -1
  198. wbportfolio/tests/viewsets/test_products.py +1 -0
  199. wbportfolio/urls.py +10 -13
  200. wbportfolio/viewsets/__init__.py +9 -4
  201. wbportfolio/viewsets/assets.py +3 -204
  202. wbportfolio/viewsets/charts/__init__.py +6 -1
  203. wbportfolio/viewsets/charts/assets.py +344 -154
  204. wbportfolio/viewsets/configs/buttons/__init__.py +2 -2
  205. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  206. wbportfolio/viewsets/configs/buttons/mixins.py +4 -4
  207. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  208. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  209. wbportfolio/viewsets/configs/display/__init__.py +2 -5
  210. wbportfolio/viewsets/configs/display/assets.py +6 -19
  211. wbportfolio/viewsets/configs/display/fees.py +3 -3
  212. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  213. wbportfolio/viewsets/configs/display/products.py +1 -1
  214. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  215. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  216. wbportfolio/viewsets/configs/display/trades.py +1 -189
  217. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -7
  218. wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
  219. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  220. wbportfolio/viewsets/configs/menu/__init__.py +1 -1
  221. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  222. wbportfolio/viewsets/configs/titles/__init__.py +2 -3
  223. wbportfolio/viewsets/configs/titles/fees.py +4 -8
  224. wbportfolio/viewsets/esg.py +3 -5
  225. wbportfolio/viewsets/mixins.py +5 -1
  226. wbportfolio/viewsets/orders/__init__.py +6 -0
  227. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  228. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  229. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +188 -0
  230. wbportfolio/viewsets/orders/configs/buttons/orders.py +113 -0
  231. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  232. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +157 -0
  233. wbportfolio/viewsets/orders/configs/displays/orders.py +232 -0
  234. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  235. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  236. wbportfolio/viewsets/orders/configs/endpoints/orders.py +28 -0
  237. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  238. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  239. wbportfolio/viewsets/orders/order_proposals.py +252 -0
  240. wbportfolio/viewsets/orders/orders.py +277 -0
  241. wbportfolio/viewsets/portfolios.py +36 -12
  242. wbportfolio/viewsets/positions.py +3 -2
  243. wbportfolio/viewsets/products.py +6 -6
  244. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  245. wbportfolio/viewsets/transactions/__init__.py +3 -14
  246. wbportfolio/viewsets/transactions/fees.py +22 -22
  247. wbportfolio/viewsets/transactions/trades.py +1 -180
  248. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +3 -1
  249. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +252 -203
  250. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  251. wbportfolio/admin/transactions/transactions.py +0 -38
  252. wbportfolio/factories/transactions.py +0 -22
  253. wbportfolio/fdm/tasks.py +0 -13
  254. wbportfolio/filters/transactions/transactions.py +0 -99
  255. wbportfolio/models/transactions/expiry.py +0 -7
  256. wbportfolio/models/transactions/trade_proposals.py +0 -704
  257. wbportfolio/pms/trading/handler.py +0 -161
  258. wbportfolio/serializers/transactions/expiry.py +0 -18
  259. wbportfolio/serializers/transactions/trade_proposals.py +0 -76
  260. wbportfolio/serializers/transactions/transactions.py +0 -85
  261. wbportfolio/tests/models/transactions/test_trade_proposals.py +0 -410
  262. wbportfolio/viewsets/configs/buttons/trade_proposals.py +0 -66
  263. wbportfolio/viewsets/configs/display/trade_proposals.py +0 -100
  264. wbportfolio/viewsets/configs/display/transactions.py +0 -55
  265. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  266. wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
  267. wbportfolio/viewsets/configs/menu/transactions.py +0 -9
  268. wbportfolio/viewsets/configs/titles/transactions.py +0 -9
  269. wbportfolio/viewsets/signals.py +0 -43
  270. wbportfolio/viewsets/transactions/trade_proposals.py +0 -139
  271. wbportfolio/viewsets/transactions/transactions.py +0 -122
  272. /wbportfolio/{fdm → api_clients}/__init__.py +0 -0
  273. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,323 @@
1
+ import logging
2
+ import math
3
+ from collections import defaultdict
4
+ from datetime import date
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING, Iterable
7
+
8
+ import pandas as pd
9
+ from celery import chain, group
10
+ from django.contrib.contenttypes.models import ContentType
11
+ from pandas._libs.tslibs.offsets import BDay
12
+ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
13
+ from wbfdm.contrib.metric.tasks import compute_metrics_as_task
14
+ from wbfdm.models import Instrument
15
+
16
+ from wbportfolio.models.asset import AssetPosition
17
+ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
18
+
19
+ if TYPE_CHECKING:
20
+ from wbportfolio.models import Portfolio
21
+ logger = logging.getLogger("pms")
22
+
23
+
24
+ MINIMUM_DECIMAL = 8
25
+ MIN_STEP = Decimal("0.00000001")
26
+
27
+
28
+ class AssetPositionBuilder:
29
+ """
30
+ Efficiently converts position data into AssetPosition models with batch operations
31
+ and proper dependency management.
32
+
33
+ Features:
34
+ - Bulk database fetching for performance
35
+ - Thread-safe operations
36
+ - Clear type hints
37
+ - Memory-efficient storage
38
+ """
39
+
40
+ _positions: dict[date, dict[tuple[int, int | None], "AssetPosition"]]
41
+
42
+ _fx_rates: dict[date, dict[Currency, CurrencyFXRates]]
43
+ _instruments: dict[int, Instrument]
44
+
45
+ def __init__(
46
+ self,
47
+ portfolio: "Portfolio",
48
+ ):
49
+ self.portfolio = portfolio
50
+ # Initialize data stores with type hints
51
+ self._instruments = {}
52
+ self._fx_rates = defaultdict(dict)
53
+ self.prices = defaultdict(dict)
54
+ self.returns = pd.DataFrame()
55
+ self._compute_metrics_tasks = set()
56
+ self._change_at_date_tasks = dict()
57
+ self._positions = defaultdict(dict)
58
+ self.excluded_positions = defaultdict(list)
59
+ self._change_at_date_kwargs = {}
60
+
61
+ def get_positions(self, fix_quantization: bool = True, **kwargs):
62
+ # return an iterable excluding the position with a null weight if the portfolio is manageable (otherwise, we assume the 0-weight position is valid)
63
+ for val_date, positions in self._positions.items():
64
+ excluded_positions = self.excluded_positions.get(val_date, [])
65
+ total_excluded_position_weight = (
66
+ sum(map(lambda o: o.weighting, excluded_positions)) if excluded_positions else Decimal("0")
67
+ )
68
+ quantization_weight_error = round(
69
+ Decimal("1") - total_excluded_position_weight - sum(map(lambda o: o.weighting, positions.values()))
70
+ if fix_quantization
71
+ else Decimal("0"),
72
+ MINIMUM_DECIMAL,
73
+ )
74
+ for position in sorted(positions.values(), key=lambda x: x.weighting, reverse=True):
75
+ if position.weighting:
76
+ for k, v in kwargs.items():
77
+ setattr(position, k, v)
78
+ # if the total weight is not 100%, we add the quantization leftover to some random position (max 1e-8 per position, thus it is negligible)
79
+ if quantization_weight_error:
80
+ step = round(Decimal(math.copysign(MIN_STEP, quantization_weight_error)), MINIMUM_DECIMAL)
81
+ position.weighting += step
82
+ quantization_weight_error -= step
83
+ yield position
84
+
85
+ def __bool__(self) -> bool:
86
+ return len(self._positions.keys()) > 0
87
+
88
+ def _get_instrument(self, instrument_id: int) -> Instrument:
89
+ try:
90
+ return self._instruments[instrument_id]
91
+ except KeyError:
92
+ instrument = Instrument.objects.get(id=instrument_id)
93
+ self._instruments[instrument_id] = instrument
94
+ return instrument
95
+
96
+ def _get_fx_rate(self, val_date: date, currency: Currency) -> CurrencyFXRates | None:
97
+ try:
98
+ return self._fx_rates[val_date][currency]
99
+ except KeyError:
100
+ if currency.key == "USD":
101
+ fx_rate = CurrencyFXRates.objects.get_or_create(
102
+ currency=currency, date=val_date, defaults={"value": Decimal("1")}
103
+ )[0]
104
+ else:
105
+ try:
106
+ fx_rate = CurrencyFXRates.objects.get(
107
+ currency=currency, date=val_date
108
+ ) # we create a fx rate anyway to not fail the position. The fx rate expect to be there later on
109
+ except CurrencyFXRates.DoesNotExist:
110
+ fx_rate = CurrencyFXRates.objects.filter(currency=currency, date__lt=val_date).latest("date")
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(
121
+ self,
122
+ val_date: date,
123
+ instrument_id: int,
124
+ weighting: float,
125
+ **kwargs,
126
+ ) -> "AssetPosition":
127
+ underlying_quote = self._get_instrument(instrument_id)
128
+ currency_fx_rate_portfolio_to_usd = self._get_fx_rate(val_date, self.portfolio.currency)
129
+ currency_fx_rate_instrument_to_usd = self._get_fx_rate(val_date, underlying_quote.currency)
130
+ if underlying_quote.is_cash:
131
+ price = Decimal("1")
132
+ else:
133
+ price = self._get_price(val_date, underlying_quote)
134
+
135
+ parameters = dict(
136
+ underlying_quote=underlying_quote,
137
+ weighting=round(weighting, MINIMUM_DECIMAL),
138
+ date=val_date,
139
+ asset_valuation_date=val_date,
140
+ is_estimated=True,
141
+ portfolio=self.portfolio,
142
+ currency=underlying_quote.currency,
143
+ initial_price=price,
144
+ currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
145
+ currency_fx_rate_instrument_to_usd=currency_fx_rate_instrument_to_usd,
146
+ initial_currency_fx_rate=None,
147
+ underlying_quote_price=None,
148
+ underlying_instrument=None,
149
+ )
150
+ parameters.update(kwargs)
151
+ position = AssetPosition(**parameters)
152
+ return position
153
+
154
+ def load_returns(self, instrument_ids: Iterable[int], from_date: date, to_date: date, use_dl: bool = True):
155
+ if self.returns.empty:
156
+ self.prices, self.returns = Instrument.objects.filter(id__in=instrument_ids).get_returns_df(
157
+ from_date=from_date, to_date=to_date, to_currency=self.portfolio.currency, use_dl=use_dl
158
+ )
159
+ else:
160
+ min_date = min(self.prices.keys())
161
+ max_date = max(self.prices.keys())
162
+ if from_date < min_date or to_date > max_date:
163
+ # we need to refetch everything as we are missing index
164
+ self.prices, self.returns = Instrument.objects.filter(
165
+ id__in=set(instrument_ids).union(set(self.returns.columns))
166
+ ).get_returns_df(
167
+ from_date=min(from_date, min_date),
168
+ to_date=max(to_date, max_date),
169
+ to_currency=self.portfolio.currency,
170
+ use_dl=use_dl,
171
+ )
172
+ else:
173
+ instruments = set(instrument_ids) - set(self.returns.columns)
174
+ if instruments:
175
+ new_prices, new_returns = Instrument.objects.filter(id__in=instruments).get_returns_df(
176
+ from_date=min(from_date, min_date),
177
+ to_date=max(to_date, max_date),
178
+ to_currency=self.portfolio.currency,
179
+ use_dl=use_dl,
180
+ )
181
+ self.returns = self.returns.join(new_returns, how="left").fillna(0)
182
+ for d, p in new_prices.items():
183
+ self.prices[d].update(p)
184
+
185
+ def add(
186
+ self,
187
+ positions: list["AssetPosition"] | tuple[date, dict[int, float]],
188
+ infer_underlying_quote_price: bool = False,
189
+ ):
190
+ """
191
+ Add multiple positions efficiently with batch processing
192
+
193
+ Args:
194
+ positions: Iterable of AssetPosition instances or dictionary of weight {instrument_id: weight} that needs to be converted into AssetPosition
195
+ """
196
+ if isinstance(positions, tuple):
197
+ val_date = positions[0]
198
+ positions = [(val_date, i, w) for i, w in positions[1].items()] # unflatten data to make it iterable
199
+ for position in positions:
200
+ if not isinstance(position, AssetPosition):
201
+ position = self._dict_to_model(*position)
202
+ position.pre_save(
203
+ infer_underlying_quote_price=infer_underlying_quote_price
204
+ ) # 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
205
+ # Generate unique composite key
206
+ key = (
207
+ position.underlying_quote.id,
208
+ position.portfolio_created.id if position.portfolio_created else None,
209
+ )
210
+ # Merge duplicate positions
211
+ if existing_position := self._positions[position.date].get(key):
212
+ position.weighting += existing_position.weighting
213
+ if existing_position.initial_shares:
214
+ position.initial_shares += existing_position.initial_shares
215
+ # ensure the position portfolio is the iterator portfolio (could be different when computing look-through for instance)
216
+ position.portfolio = self.portfolio
217
+ position.weighting = Decimal(
218
+ round(position.weighting, 8)
219
+ ) # set the weight as it will be saved in the db to handle quantization error accordingly
220
+ if position.initial_price is not None and position.initial_currency_fx_rate is not None:
221
+ self._positions[position.date][key] = position
222
+ else:
223
+ self.excluded_positions[position.date].append(position)
224
+ self._change_at_date_kwargs["fix_quantization"] = True
225
+ return self
226
+
227
+ def get_dates(self) -> list[date]:
228
+ """Get sorted list of unique dates"""
229
+ return list(sorted(self._positions.keys()))
230
+
231
+ def _get_portfolio(self, val_date: date) -> AnalyticPortfolio:
232
+ """Get weight structure with instrument IDs as keys"""
233
+ positions = self._positions[val_date]
234
+ next_returns = self.returns.loc[[(val_date + BDay(1)).date()], :]
235
+ weights = dict(map(lambda row: (row[1].underlying_quote.id, float(row[1].weighting)), positions.items()))
236
+ return AnalyticPortfolio(weights=weights, X=next_returns)
237
+
238
+ def bulk_create_positions(self, delete_leftovers: bool = False, force_save: bool = False, **kwargs):
239
+ positions = list(self.get_positions(**kwargs))
240
+ # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
241
+ # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
242
+ # change completely the trades of a portfolio model and drift it.
243
+ dates = self.get_dates()
244
+ self.portfolio.assets.filter(date__in=dates, is_estimated=True).delete()
245
+
246
+ if len(positions) > 0:
247
+ if self.portfolio.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
248
+ leftover_positions_ids = list(
249
+ self.portfolio.assets.filter(date__in=dates).values_list("id", flat=True)
250
+ ) # we need to get the ids otherwise the queryset is reevaluated later
251
+ logger.info(f"bulk saving {len(positions)} positions ({len(leftover_positions_ids)} leftovers) ...")
252
+ objs = AssetPosition.unannotated_objects.bulk_create(
253
+ positions,
254
+ update_fields=[
255
+ "weighting",
256
+ "initial_price",
257
+ "initial_currency_fx_rate",
258
+ "initial_shares",
259
+ "currency_fx_rate_instrument_to_usd",
260
+ "currency_fx_rate_portfolio_to_usd",
261
+ "underlying_quote_price",
262
+ "portfolio",
263
+ "portfolio_created",
264
+ "underlying_instrument",
265
+ ],
266
+ unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
267
+ update_conflicts=True,
268
+ batch_size=10000,
269
+ )
270
+ if delete_leftovers:
271
+ objs_ids = list(map(lambda x: x.id, objs))
272
+ leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
273
+ logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
274
+ AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
275
+
276
+ for val_date in self.get_dates():
277
+ if self.portfolio.is_tracked:
278
+ try:
279
+ changed_portfolio = self._get_portfolio(val_date)
280
+ except KeyError:
281
+ changed_portfolio = None
282
+ self._change_at_date_tasks[val_date] = changed_portfolio
283
+ self._compute_metrics_tasks.add(val_date)
284
+ self._positions = defaultdict(dict)
285
+
286
+ def clear(self):
287
+ self.excluded_positions = defaultdict(list)
288
+
289
+ def schedule_metric_computation(self):
290
+ if self._compute_metrics_tasks:
291
+ basket_id = self.portfolio.id
292
+ basket_content_type_id = ContentType.objects.get_by_natural_key("wbportfolio", "portfolio").id
293
+ group(
294
+ *[
295
+ compute_metrics_as_task.si(d, basket_id=basket_id, basket_content_type_id=basket_content_type_id)
296
+ for d in self._compute_metrics_tasks
297
+ ]
298
+ ).apply_async()
299
+ self._change_at_date_tasks = dict()
300
+
301
+ def schedule_change_at_dates(self, synchronous: bool = True, **task_kwargs):
302
+ from wbportfolio.models.portfolio import trigger_portfolio_change_as_task
303
+
304
+ change_at_date_kwargs = task_kwargs
305
+ change_at_date_kwargs.update(self._change_at_date_kwargs)
306
+ if self._change_at_date_tasks:
307
+ tasks = chain(
308
+ *[
309
+ trigger_portfolio_change_as_task.si(
310
+ self.portfolio.id,
311
+ d,
312
+ changed_portfolio=portfolio,
313
+ evaluate_rebalancer=False,
314
+ **change_at_date_kwargs,
315
+ )
316
+ for d, portfolio in self._change_at_date_tasks.items()
317
+ ]
318
+ )
319
+ if synchronous:
320
+ tasks.apply()
321
+ else:
322
+ tasks.apply_async()
323
+ self._change_at_date_tasks = dict()
@@ -32,7 +32,7 @@ class Custodian(WBModel):
32
32
 
33
33
  @classmethod
34
34
  def get_by_mapping(cls, mapping: str, use_similarity=False, create_missing=True):
35
- SIMILIRATY_SCORE = 0.7
35
+ similarity_score = 0.7
36
36
  lower_mapping = mapping.lower()
37
37
  try:
38
38
  return cls.objects.get(mapping__contains=[lower_mapping])
@@ -40,7 +40,7 @@ class Custodian(WBModel):
40
40
  if use_similarity:
41
41
  similar_custodians = cls.objects.annotate(
42
42
  similarity_score=TrigramSimilarity("name", lower_mapping)
43
- ).filter(similarity_score__gt=SIMILIRATY_SCORE)
43
+ ).filter(similarity_score__gt=similarity_score)
44
44
  if similar_custodians.count() == 1:
45
45
  custodian = similar_custodians.first()
46
46
  print(f"find similar custodian {lower_mapping} -> {custodian.name}") # noqa: T201
@@ -50,7 +50,7 @@ class Custodian(WBModel):
50
50
  else:
51
51
  similar_companies = Company.objects.annotate(
52
52
  similarity_score=TrigramSimilarity("name", lower_mapping)
53
- ).filter(similarity_score__gt=SIMILIRATY_SCORE)
53
+ ).filter(similarity_score__gt=similarity_score)
54
54
  if similar_companies.count() == 1:
55
55
  print( # noqa: T201
56
56
  f"Find similar company {lower_mapping} -> {similar_companies.first().name}"
@@ -1,2 +1,2 @@
1
- class InvalidAnalyticPortfolio(Exception):
1
+ class InvalidAnalyticPortfolioError(Exception):
2
2
  pass
@@ -120,7 +120,7 @@ class PortfolioGraph:
120
120
  if rel.dependency_portfolio.is_composition:
121
121
  label += " (Composition)"
122
122
 
123
- if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.PRIMARY:
123
+ if rel.portfolio.is_lookthrough and rel.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH:
124
124
  self.graph.add_edge(
125
125
  pydot.Edge(
126
126
  str(rel.portfolio.id), str(rel.dependency_portfolio.id), label="Look-Through", style="dotted"
@@ -3,21 +3,21 @@ import plotly.graph_objects as go
3
3
  from networkx.drawing.nx_agraph import graphviz_layout
4
4
 
5
5
 
6
- def reformat_graph_layout(G, layout):
6
+ def reformat_graph_layout(g, layout):
7
7
  """
8
8
  this method provide positions based on layout algorithm
9
- :param G:
9
+ :param g:
10
10
  :param layout:
11
11
  :return:
12
12
  """
13
13
  if layout == "graphviz":
14
- positions = graphviz_layout(G)
14
+ positions = graphviz_layout(g)
15
15
  elif layout == "spring":
16
- positions = nx.fruchterman_reingold_layout(G, k=0.5, iterations=1000)
16
+ positions = nx.fruchterman_reingold_layout(g, k=0.5, iterations=1000)
17
17
  elif layout == "spectral":
18
- positions = nx.spectral_layout(G, scale=0.1)
18
+ positions = nx.spectral_layout(g, scale=0.1)
19
19
  elif layout == "random":
20
- positions = nx.random_layout(G)
20
+ positions = nx.random_layout(g)
21
21
  else:
22
22
  raise Exception("please specify the layout from graphviz, spring, spectral or random")
23
23
 
@@ -25,7 +25,7 @@ def reformat_graph_layout(G, layout):
25
25
 
26
26
 
27
27
  def networkx_graph_to_plotly(
28
- G: nx.Graph,
28
+ g: nx.Graph,
29
29
  labels: dict[str, str] | None = None,
30
30
  node_size: int = 10,
31
31
  edge_weight: int = 1,
@@ -36,12 +36,12 @@ def networkx_graph_to_plotly(
36
36
  """
37
37
  Visualize a NetworkX graph using Plotly.
38
38
  """
39
- positions = reformat_graph_layout(G, layout)
39
+ positions = reformat_graph_layout(g, layout)
40
40
  if not labels:
41
41
  labels = {}
42
42
  # Initialize edge traces
43
43
  edge_traces = []
44
- for edge in G.edges():
44
+ for edge in g.edges():
45
45
  x0, y0 = positions[edge[0]]
46
46
  x1, y1 = positions[edge[1]]
47
47
 
@@ -52,12 +52,12 @@ def networkx_graph_to_plotly(
52
52
 
53
53
  # Initialize node trace
54
54
  node_x, node_y, node_colors, node_labels = [], [], [], []
55
- for node in G.nodes():
55
+ for node in g.nodes():
56
56
  x, y = positions[node]
57
57
  node_x.append(x)
58
58
  node_y.append(y)
59
59
  node_labels.append(labels.get(node, node))
60
- node_colors.append(len(list(G.neighbors(node)))) # Color based on degree
60
+ node_colors.append(len(list(g.neighbors(node)))) # Color based on degree
61
61
 
62
62
  node_trace = go.Scatter(
63
63
  x=node_x,
@@ -150,6 +150,13 @@ class PMSInstrumentAbstractModel(PMSInstrument):
150
150
  default="wbportfolio.models.portfolio.default_estimate_net_value",
151
151
  verbose_name="NAV Computation Method",
152
152
  )
153
+ order_routing_custodian_adapter = models.CharField(
154
+ blank=True,
155
+ null=True,
156
+ max_length=1024,
157
+ verbose_name="Order Routing Custodian Adapter",
158
+ help_text="The dotted path to the order routing custodian adapter",
159
+ )
153
160
  risk_scale = models.IntegerField(
154
161
  validators=[MinValueValidator(1), MaxValueValidator(7)],
155
162
  default=4,
@@ -834,7 +834,7 @@ class LiquidityStressMixin:
834
834
 
835
835
  """ The main function for the liquidity stress tests """
836
836
 
837
- def liquidity_stress_test(
837
+ def liquidity_stress_test( # noqa: C901
838
838
  self,
839
839
  report_date: Optional[date] = None,
840
840
  weights_date: Optional[date] = None,
@@ -966,7 +966,7 @@ class LiquidityStressMixin:
966
966
 
967
967
  qs_trades = Trade.objects.filter(
968
968
  underlying_instrument__in=product_ids,
969
- transaction_subtype__in=["SUBSCRIPTION", "REDEMPTION"],
969
+ type__in=["SUBSCRIPTION", "REDEMPTION"],
970
970
  transaction_date__lte=report_date,
971
971
  ).order_by("transaction_date")
972
972
  if not qs_trades.exists():
@@ -974,7 +974,7 @@ class LiquidityStressMixin:
974
974
 
975
975
  trades_fields = [
976
976
  "transaction_date",
977
- "transaction_subtype",
977
+ "type",
978
978
  "underlying_instrument",
979
979
  "underlying_instrument__currency",
980
980
  "total_value",
@@ -999,7 +999,7 @@ class LiquidityStressMixin:
999
999
 
1000
1000
  # df_trades.transaction_date = pd.to_datetime(df_trades["transaction_date"]) # to use df.rolling
1001
1001
  # Gross Redemption
1002
- df_gross_redemption = df_trades.where(df_trades.transaction_subtype == "REDEMPTION")
1002
+ df_gross_redemption = df_trades.where(df_trades.type == "REDEMPTION")
1003
1003
  df_gross_redemption = df_gross_redemption.groupby("date").total_value_usd.sum()
1004
1004
  df_gross_redemption.name = "gross_redemption"
1005
1005
  df_gross_redemption = df_aum.join(df_gross_redemption)
@@ -0,0 +1,2 @@
1
+ from .orders import Order
2
+ from .order_proposals import OrderProposal