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
@@ -1,42 +1,36 @@
1
1
  import logging
2
+ from collections import defaultdict
2
3
  from contextlib import suppress
3
4
  from datetime import date, timedelta
4
5
  from decimal import Decimal
5
- from math import isclose
6
- from typing import TYPE_CHECKING, Any, Iterable
6
+ from typing import TYPE_CHECKING, Any, Generator, Iterable
7
7
 
8
8
  import numpy as np
9
9
  import pandas as pd
10
10
  from celery import shared_task
11
- from django.contrib.contenttypes.models import ContentType
12
11
  from django.contrib.postgres.fields import DateRangeField
12
+ from django.core.exceptions import ObjectDoesNotExist
13
+ from django.core.validators import MaxValueValidator, MinValueValidator
13
14
  from django.db import models
14
- from django.db.models import (
15
- Exists,
16
- F,
17
- OuterRef,
18
- Q,
19
- QuerySet,
20
- Sum,
21
- Value,
22
- )
15
+ from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum
23
16
  from django.db.models.signals import post_save
24
17
  from django.dispatch import receiver
25
18
  from django.utils import timezone
26
19
  from django.utils.functional import cached_property
27
20
  from pandas._libs.tslibs.offsets import BDay
28
- from skfolio.preprocessing import prices_to_returns
29
- from wbcore.contrib.currency.models import Currency, CurrencyFXRates
21
+ from wbcore.contrib.authentication.models import User
22
+ from wbcore.contrib.currency.models import Currency
23
+ from wbcore.contrib.notifications.dispatch import send_notification
30
24
  from wbcore.contrib.notifications.utils import create_notification_type
31
25
  from wbcore.models import WBModel
32
26
  from wbcore.utils.importlib import import_from_dotted_path
33
27
  from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
34
- from wbfdm.contrib.metric.tasks import compute_metrics_as_task
35
- from wbfdm.models import Instrument, InstrumentType
28
+ from wbfdm.models import Cash, Instrument, InstrumentType
36
29
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
37
30
  from wbfdm.signals import investable_universe_updated
38
31
 
39
- from wbportfolio.models.asset import AssetPosition, AssetPositionIterator
32
+ from wbportfolio.models.asset import AssetPosition
33
+ from wbportfolio.models.builder import AssetPositionBuilder
40
34
  from wbportfolio.models.indexes import Index
41
35
  from wbportfolio.models.portfolio_relationship import (
42
36
  InstrumentPortfolioThroughModel,
@@ -45,78 +39,17 @@ from wbportfolio.models.portfolio_relationship import (
45
39
  from wbportfolio.models.products import Product
46
40
  from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
47
41
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
42
+ from wbportfolio.pms.typing import Position as PositionDTO
48
43
 
49
- from . import ProductGroup
50
- from .exceptions import InvalidAnalyticPortfolio
44
+ from ..constants import EQUITY_TYPE_KEYS
45
+ from ..order_routing.adapters import BaseCustodianAdapter
46
+ from . import PortfolioRole, ProductGroup
51
47
 
52
48
  logger = logging.getLogger("pms")
53
49
  if TYPE_CHECKING:
54
- from wbportfolio.models.transactions.trade_proposals import TradeProposal
55
-
56
-
57
- def get_prices(instrument_ids: list[int], from_date: date, to_date: date) -> dict[date, dict[int, float]]:
58
- """
59
- Utility to fetch raw prices
60
- """
61
- prices = InstrumentPrice.objects.filter(instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date)
62
- df = (
63
- pd.DataFrame(
64
- prices.filter_only_valid_prices().values_list("instrument", "net_value", "date"),
65
- columns=["instrument", "net_value", "date"],
66
- )
67
- .pivot_table(index="date", values="net_value", columns="instrument")
68
- .astype(float)
69
- .sort_index()
70
- )
71
- ts = pd.bdate_range(df.index.min(), df.index.max(), freq="B")
72
- df = df.reindex(ts)
73
- df = df.ffill()
74
- df.index = pd.to_datetime(df.index)
75
- return {ts.date(): row for ts, row in df.to_dict("index").items()}
76
-
77
-
78
- def get_returns(
79
- instrument_ids: list[int],
80
- from_date: date,
81
- to_date: date,
82
- to_currency: Currency | None = None,
83
- ffill_returns: bool = True,
84
- ) -> pd.DataFrame:
85
- """
86
- Utility methods to get instrument returns for a given date range
87
-
88
- Args:
89
- from_date: date range lower bound
90
- to_date: date range upper bound
91
-
92
- Returns:
93
- Return a tuple of the returns and the last prices series for conveniance
94
- """
95
- if to_currency:
96
- fx_rate = CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", to_currency)
97
- else:
98
- fx_rate = Value(Decimal(1.0))
99
- prices = InstrumentPrice.objects.filter(
100
- instrument__in=instrument_ids, date__gte=from_date, date__lte=to_date
101
- ).annotate(fx_rate=fx_rate, price_fx_portfolio=F("net_value") * F("fx_rate"))
102
- prices_df = (
103
- pd.DataFrame(
104
- prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
105
- columns=["instrument", "price_fx_portfolio", "date"],
106
- )
107
- .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
108
- .astype(float)
109
- .sort_index()
110
- )
111
- if prices_df.empty:
112
- raise InvalidAnalyticPortfolio()
113
- ts = pd.bdate_range(prices_df.index.min(), prices_df.index.max(), freq="B")
114
- prices_df = prices_df.reindex(ts)
115
- if ffill_returns:
116
- prices_df = prices_df.ffill()
117
- prices_df.index = pd.to_datetime(prices_df.index)
118
- returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
119
- return returns.replace([np.inf, -np.inf, np.nan], 0)
50
+ pass
51
+
52
+ MARKET_HOLIDAY_MAX_DURATION = 15
120
53
 
121
54
 
122
55
  class DefaultPortfolioQueryset(QuerySet):
@@ -142,7 +75,7 @@ class DefaultPortfolioQueryset(QuerySet):
142
75
  """
143
76
  A method to sort the given queryset to return undependable portfolio first. This is very useful if a routine needs to be applied sequentially on portfolios by order of dependence.
144
77
  """
145
- MAX_ITERATIONS: int = (
78
+ max_iterations: int = (
146
79
  5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
147
80
  )
148
81
  remaining_portfolios = set(self)
@@ -155,7 +88,7 @@ class DefaultPortfolioQueryset(QuerySet):
155
88
  dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
156
89
  portfolio=p, dependency_portfolio__in=remaining_portfolios
157
90
  ) # get dependency portfolios
158
- if iterator_counter >= MAX_ITERATIONS or (
91
+ if iterator_counter >= max_iterations or (
159
92
  not dependency_relationships.exists() and not bool(parent_portfolios)
160
93
  ): # if not dependency portfolio or parent portfolio that remained, then we yield
161
94
  remaining_portfolios.remove(p)
@@ -196,28 +129,32 @@ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
196
129
 
197
130
  class PortfolioPortfolioThroughModel(models.Model):
198
131
  class Type(models.TextChoices):
199
- PRIMARY = "PRIMARY", "Primary"
132
+ LOOK_THROUGH = "LOOK_THROUGH", "Look-through"
200
133
  MODEL = "MODEL", "Model"
201
134
  CUSTODIAN = "CUSTODIAN", "Custodian"
135
+ HIERARCHICAL = "HIERARCHICAL", "Hierarchical"
202
136
 
203
137
  portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
204
138
  dependency_portfolio = models.ForeignKey(
205
139
  "wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependent_through"
206
140
  )
207
- type = models.CharField(choices=Type.choices, default=Type.PRIMARY, verbose_name="Type")
141
+ type = models.CharField(choices=Type.choices, default=Type.LOOK_THROUGH, verbose_name="Type")
208
142
 
209
143
  def __str__(self):
210
144
  return f"{self.portfolio} dependant on {self.dependency_portfolio} ({self.Type[self.type].label})"
211
145
 
212
146
  class Meta:
213
147
  constraints = [
214
- models.UniqueConstraint(fields=["portfolio", "type"], name="unique_primary", condition=Q(type="PRIMARY")),
148
+ models.UniqueConstraint(
149
+ fields=["portfolio", "type"], name="unique_lookthrough", condition=Q(type="LOOK_THROUGH")
150
+ ),
215
151
  models.UniqueConstraint(fields=["portfolio", "type"], name="unique_model", condition=Q(type="MODEL")),
216
152
  ]
217
153
 
218
154
 
219
155
  class Portfolio(DeleteToDisableMixin, WBModel):
220
156
  assets: models.QuerySet[AssetPosition]
157
+ builder: AssetPositionBuilder
221
158
 
222
159
  name = models.CharField(
223
160
  max_length=255,
@@ -275,7 +212,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
275
212
 
276
213
  is_manageable = models.BooleanField(
277
214
  default=False,
278
- help_text="True if the portfolio can be manually modified (e.g. Trade proposal be submitted or total weight recomputed)",
215
+ help_text="True if the portfolio can be manually modified (e.g. Order Proposal be submitted or total weight recomputed)",
279
216
  )
280
217
  is_tracked = models.BooleanField(
281
218
  default=True,
@@ -303,14 +240,37 @@ class Portfolio(DeleteToDisableMixin, WBModel):
303
240
  blank=True,
304
241
  )
305
242
 
243
+ # OMS default parameters. Used to seed order proposal default value upon creation
244
+ default_order_proposal_min_order_value = models.IntegerField(
245
+ default=0, verbose_name="Default Order Proposal Minimum Order Value"
246
+ )
247
+ default_order_proposal_min_weighting = models.DecimalField(
248
+ max_digits=9,
249
+ decimal_places=8,
250
+ default=Decimal(0),
251
+ verbose_name="Default Order Proposal Minimum Weight",
252
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
253
+ )
254
+ default_order_proposal_total_cash_weight = models.DecimalField(
255
+ default=Decimal("0"),
256
+ decimal_places=4,
257
+ max_digits=5,
258
+ verbose_name="Default Order Proposal Total Cash Weight",
259
+ validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
260
+ )
261
+
306
262
  objects = DefaultPortfolioManager()
307
263
  tracked_objects = ActiveTrackedPortfolioManager()
308
264
 
265
+ def __init__(self, *args, **kwargs):
266
+ self.builder = AssetPositionBuilder(self)
267
+ super().__init__(*args, **kwargs)
268
+
309
269
  @property
310
270
  def primary_portfolio(self):
311
271
  with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
312
272
  return PortfolioPortfolioThroughModel.objects.get(
313
- portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY
273
+ portfolio=self, type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
314
274
  ).dependency_portfolio
315
275
 
316
276
  @property
@@ -329,6 +289,13 @@ class Portfolio(DeleteToDisableMixin, WBModel):
329
289
  dependency_portfolio__is_composition=True,
330
290
  ).dependency_portfolio
331
291
 
292
+ @property
293
+ def is_model(self) -> bool:
294
+ return PortfolioPortfolioThroughModel.objects.filter(
295
+ type=PortfolioPortfolioThroughModel.Type.MODEL,
296
+ dependency_portfolio=self,
297
+ ).exists()
298
+
332
299
  @property
333
300
  def imported_assets(self):
334
301
  return self.assets.filter(is_estimated=False)
@@ -340,6 +307,30 @@ class Portfolio(DeleteToDisableMixin, WBModel):
340
307
  instruments.extend([i for i in Index.objects.filter(portfolios=self)])
341
308
  return instruments
342
309
 
310
+ @property
311
+ def cash_component(self) -> Cash:
312
+ return Cash.objects.get_or_create(
313
+ currency=self.currency, defaults={"is_cash": True, "name": self.currency.title}
314
+ )[0]
315
+
316
+ def get_authenticated_custodian_adapter(self, **kwargs) -> BaseCustodianAdapter | None:
317
+ supported_instruments_for_routing = list(
318
+ filter(lambda o: o.order_routing_custodian_adapter, self.pms_instruments)
319
+ )
320
+ if not supported_instruments_for_routing:
321
+ raise ValueError("No custodian adapter for this portfolio")
322
+
323
+ pms_instrument = supported_instruments_for_routing[
324
+ 0
325
+ ] # for simplicity we support only one instrument per portfolio that is allowed to support order routing
326
+ adapter = import_from_dotted_path(pms_instrument.order_routing_custodian_adapter)(
327
+ isin=pms_instrument.isin, identifier=pms_instrument.identifier, **kwargs
328
+ )
329
+ adapter.authenticate()
330
+ if not adapter.is_valid():
331
+ raise ValueError("This portfolio is not valid for rebalancing")
332
+ return adapter
333
+
343
334
  @property
344
335
  def can_be_rebalanced(self):
345
336
  return self.is_manageable and not self.is_lookthrough
@@ -355,9 +346,17 @@ class Portfolio(DeleteToDisableMixin, WBModel):
355
346
 
356
347
  def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
357
348
  "returns the dto representation of this portfolio at the specified date"
358
- return PortfolioDTO(
359
- tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_kwargs)]),
360
- )
349
+ assets = self.assets.filter(date=val_date, **extra_kwargs)
350
+ try:
351
+ last_returns, _ = self.get_analytic_portfolio(val_date, use_dl=True).get_contributions()
352
+ last_returns = last_returns.to_dict()
353
+ except ValueError:
354
+ last_returns = {}
355
+ positions = []
356
+ for asset in assets:
357
+ positions.append(asset._build_dto(daily_return=last_returns.get(asset.underlying_quote.id, Decimal("0"))))
358
+
359
+ return PortfolioDTO(positions)
361
360
 
362
361
  def get_weights(self, val_date: date) -> dict[int, float]:
363
362
  """
@@ -380,7 +379,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
380
379
  )
381
380
 
382
381
  def get_analytic_portfolio(
383
- self, val_date: date, weights: dict[int, float] | None = None, **kwargs
382
+ self, val_date: date, weights: dict[int, float] | None = None, use_dl: bool = True, **kwargs
384
383
  ) -> AnalyticPortfolio:
385
384
  """
386
385
  Return the analytic portfolio associated with this portfolio at the given date
@@ -395,11 +394,10 @@ class Portfolio(DeleteToDisableMixin, WBModel):
395
394
  if not weights:
396
395
  weights = self.get_weights(val_date)
397
396
  return_date = (val_date + BDay(1)).date()
398
- returns = get_returns(
399
- list(weights.keys()), (val_date - BDay(2)).date(), return_date, to_currency=self.currency, **kwargs
400
- )
397
+ returns = self.load_builder_returns(val_date, return_date, use_dl=use_dl).copy()
401
398
  if pd.Timestamp(return_date) not in returns.index:
402
- raise InvalidAnalyticPortfolio()
399
+ raise ValueError()
400
+ returns = returns.loc[:return_date, :]
403
401
  returns = returns.fillna(0) # not sure this is what we want
404
402
  return AnalyticPortfolio(
405
403
  X=returns,
@@ -430,12 +428,22 @@ class Portfolio(DeleteToDisableMixin, WBModel):
430
428
  True,
431
429
  ),
432
430
  create_notification_type(
433
- "wbportfolio.portfolio.replay_done",
434
- "Portfolio Replay finished",
435
- "Sends a notification when a the requested trade proposal replay is done",
431
+ "wbportfolio.portfolio.action_done",
432
+ "Portfolio Action finished",
433
+ "Sends a notification when a the requested portfolio action is done (e.g. replay, quote adjustment...)",
434
+ True,
435
+ True,
436
+ True,
437
+ is_lock=True,
438
+ ),
439
+ create_notification_type(
440
+ "wbportfolio.portfolio.warning",
441
+ "PMS Warning",
442
+ "Sends a notification to warn portfolio manager or administrator regarding issue that needs action.",
436
443
  True,
437
444
  True,
438
445
  True,
446
+ is_lock=True,
439
447
  ),
440
448
  ]
441
449
 
@@ -565,7 +573,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
565
573
  val_date=val_date,
566
574
  exclude_cash=True,
567
575
  exclude_index=True,
568
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
576
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
569
577
  **kwargs,
570
578
  )
571
579
  if not df.empty:
@@ -578,7 +586,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
578
586
  val_date=val_date,
579
587
  exclude_cash=True,
580
588
  exclude_index=True,
581
- extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
589
+ extra_filter_parameters={"underlying_instrument__instrument_type__key__in": EQUITY_TYPE_KEYS},
582
590
  **kwargs,
583
591
  )
584
592
  if not df.empty:
@@ -650,7 +658,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
650
658
  ) -> pd.DataFrame:
651
659
  qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
652
660
  if only_equity:
653
- qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
661
+ qs = qs.filter(underlying_instrument__instrument_type__key__in=EQUITY_TYPE_KEYS)
654
662
  qs = qs.annotate_hedged_currency_fx_rate(hedged_currency)
655
663
  df = Portfolio.get_contribution_df(
656
664
  qs.select_related("underlying_instrument").values_list(
@@ -690,42 +698,49 @@ class Portfolio(DeleteToDisableMixin, WBModel):
690
698
  if portfolio := asset.underlying_instrument.portfolio:
691
699
  yield portfolio, asset.weighting
692
700
 
701
+ def get_next_rebalancing_date(self, start_date: date) -> date | None:
702
+ if automatic_rebalancer := getattr(self, "automatic_rebalancer", None):
703
+ return automatic_rebalancer.get_next_rebalancing_date(start_date)
704
+
705
+ def fix_quantization(self, val_date: date):
706
+ assets = self.assets.filter(date=val_date)
707
+ total_weighting = assets.aggregate(s=Sum("weighting"))["s"]
708
+ if total_weighting and (quantization_error := Decimal("1") - total_weighting):
709
+ cash = self.cash_component
710
+ try:
711
+ cash_pos = assets.get(underlying_quote=cash)
712
+ cash_pos.weighting += quantization_error
713
+ except AssetPosition.DoesNotExist:
714
+ cash_pos = AssetPosition(
715
+ portfolio=self,
716
+ underlying_quote=cash,
717
+ weighting=quantization_error,
718
+ initial_price=Decimal("1"),
719
+ date=val_date,
720
+ is_estimated=True,
721
+ )
722
+ cash_pos.save(create_underlying_quote_price_if_missing=True)
723
+
693
724
  def change_at_date(
694
725
  self,
695
726
  val_date: date,
696
- recompute_weighting: bool = False,
697
- force_recompute_weighting: bool = False,
727
+ fix_quantization: bool = False,
698
728
  evaluate_rebalancer: bool = True,
699
- changed_weights: dict[int, float] | None = None,
729
+ changed_portfolio: AnalyticPortfolio | None = None,
730
+ broadcast_changes_at_date: bool = True,
731
+ **kwargs,
700
732
  ):
733
+ if not self.is_tracked:
734
+ return
701
735
  logger.info(f"change at date for {self} at {val_date}")
702
736
 
703
- if recompute_weighting:
704
- # We normalize weight across the portfolio for a given date
705
- qs = self.assets.filter(date=val_date).filter(
706
- Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
707
- )
708
- if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
709
- total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
710
- # We check if this actually necessary
711
- # (i.e. if the weight is already summed to 100%, it is already normalized)
712
- if (
713
- not total_weighting
714
- or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001)
715
- or force_recompute_weighting
716
- ):
717
- total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
718
- # TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
719
- for asset in qs:
720
- if total_value:
721
- asset.weighting = asset._total_value_fx_portfolio / total_value
722
- elif total_weighting:
723
- asset.weighting = asset.weighting / total_weighting
724
- asset.save()
737
+ if fix_quantization:
738
+ # We assume all ptf total weight is 100% but quantization error can occur. In that case, we create a cash component and add the weight there.
739
+ self.fix_quantization(val_date)
725
740
 
726
741
  # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
727
742
  self.estimate_net_asset_values(
728
- (val_date + BDay(1)).date(), weights=changed_weights
743
+ (val_date + BDay(1)).date(), analytic_portfolio=changed_portfolio
729
744
  ) # updating weighting in t0 influence nav in t+1
730
745
  if evaluate_rebalancer:
731
746
  self.evaluate_rebalancing(val_date)
@@ -737,25 +752,44 @@ class Portfolio(DeleteToDisableMixin, WBModel):
737
752
  if not self.initial_position_date or self.initial_position_date > val_date:
738
753
  self.initial_position_date = val_date
739
754
  self.save()
755
+ if broadcast_changes_at_date:
756
+ self.handle_controlling_portfolio_change_at_date(
757
+ val_date,
758
+ fix_quantization=fix_quantization,
759
+ changed_portfolio=changed_portfolio,
760
+ **kwargs,
761
+ )
740
762
 
741
- self.handle_controlling_portfolio_change_at_date(val_date)
742
-
743
- def handle_controlling_portfolio_change_at_date(self, val_date: date):
744
- for rel in PortfolioPortfolioThroughModel.objects.filter(
745
- dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
746
- ):
747
- rel.portfolio.compute_lookthrough(val_date)
763
+ def handle_controlling_portfolio_change_at_date(self, val_date: date, **kwargs):
764
+ if self.is_tracked:
765
+ for rel in PortfolioPortfolioThroughModel.objects.filter(
766
+ dependency_portfolio=self,
767
+ type=PortfolioPortfolioThroughModel.Type.LOOK_THROUGH,
768
+ portfolio__is_lookthrough=True,
769
+ ):
770
+ rel.portfolio.compute_lookthrough(val_date)
771
+ for rel in PortfolioPortfolioThroughModel.objects.filter(
772
+ dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
773
+ ):
774
+ rel.portfolio.evaluate_rebalancing(val_date)
775
+ for dependent_portfolio in self.get_child_portfolios(val_date):
776
+ # dependent_portfolio.change_at_date(val_date, **kwargs)
777
+ dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date, **kwargs)
778
+
779
+ def get_model_portfolio_relationships(
780
+ self, val_date: date
781
+ ) -> Generator[PortfolioPortfolioThroughModel, None, None]:
748
782
  for rel in PortfolioPortfolioThroughModel.objects.filter(
749
783
  dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
750
784
  ):
751
- rel.portfolio.evaluate_rebalancing(val_date)
785
+ if rel.portfolio.is_active_at_date(val_date):
786
+ yield rel
752
787
  for dependent_portfolio in self.get_child_portfolios(val_date):
753
- dependent_portfolio.change_at_date(val_date)
754
- dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
788
+ yield from dependent_portfolio.get_model_portfolio_relationships(val_date)
755
789
 
756
790
  def evaluate_rebalancing(self, val_date: date):
757
791
  if hasattr(self, "automatic_rebalancer"):
758
- # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a trade proposal automatically
792
+ # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a order proposal automatically
759
793
  next_business_date = (val_date + BDay(1)).date()
760
794
  if self.automatic_rebalancer.is_valid(val_date): # we evaluate the rebalancer in t0 and t+1
761
795
  logger.info(f"Evaluate Rebalancing for {self} at {val_date}")
@@ -764,83 +798,102 @@ class Portfolio(DeleteToDisableMixin, WBModel):
764
798
  logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
765
799
  self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
766
800
 
767
- def estimate_net_asset_values(self, val_date: date, weights: dict[int | float] | None = None):
768
- for instrument in self.pms_instruments:
769
- if instrument.is_active_at_date(val_date) and (
770
- net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path
771
- ):
772
- logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
773
- net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
774
- estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument, weights=weights)
775
- if estimated_net_asset_value is not None:
776
- InstrumentPrice.objects.update_or_create(
777
- instrument=instrument,
778
- date=val_date,
779
- calculated=True,
780
- defaults={
781
- "gross_value": estimated_net_asset_value,
782
- "net_value": estimated_net_asset_value,
783
- },
801
+ def estimate_net_asset_values(self, val_date: date, analytic_portfolio: AnalyticPortfolio | None = None):
802
+ effective_portfolio_date = (val_date - BDay(1)).date()
803
+ with suppress(ValueError):
804
+ if not analytic_portfolio:
805
+ analytic_portfolio = self.get_analytic_portfolio(effective_portfolio_date, use_dl=False)
806
+ for instrument in self.pms_instruments:
807
+ # we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
808
+ last_price = instrument.get_latest_price(effective_portfolio_date)
809
+ if (
810
+ instrument.is_active_at_date(val_date)
811
+ and (net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path)
812
+ and last_price
813
+ ):
814
+ logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
815
+ net_asset_value_computation_method = import_from_dotted_path(
816
+ net_asset_value_computation_method_path
784
817
  )
785
- if (
786
- val_date == instrument.last_price_date
787
- ): # if price date is the latest instrument price date, we recompute the last valuation data
788
- instrument.update_last_valuation_date()
789
-
790
- def drift_weights(self, start_date: date, end_date: date) -> tuple[AssetPositionIterator, "TradeProposal"]:
818
+ estimated_net_asset_value = net_asset_value_computation_method(last_price, analytic_portfolio)
819
+ if estimated_net_asset_value is not None:
820
+ InstrumentPrice.objects.update_or_create(
821
+ instrument=instrument,
822
+ date=val_date,
823
+ calculated=True,
824
+ defaults={
825
+ "gross_value": estimated_net_asset_value,
826
+ "net_value": estimated_net_asset_value,
827
+ },
828
+ )
829
+ if (
830
+ val_date == instrument.last_price_date
831
+ ): # if price date is the latest instrument price date, we recompute the last valuation data
832
+ instrument.update_last_valuation_date()
833
+
834
+ def drift_weights(
835
+ self, start_date: date, end_date: date, stop_at_rebalancing: bool = False
836
+ ) -> Generator[tuple[date, dict[int, float]], None, models.Model]:
791
837
  logger.info(f"drift weights for {self} from {start_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
838
+
792
839
  rebalancer = getattr(self, "automatic_rebalancer", None)
793
840
  # Get initial weights
794
841
  weights = self.get_weights(start_date) # initial weights
795
842
  if not weights:
796
843
  previous_date = self.assets.filter(date__lte=start_date).latest("date").date
797
- drifted_positions, _ = self.drift_weights(previous_date, start_date)
798
- weights = drifted_positions.get_weights()[start_date]
799
-
800
- # Get returns and prices data for the whole date range
801
- instrument_ids = list(weights.keys())
802
- returns = get_returns(
803
- instrument_ids,
804
- (start_date - BDay(3)).date(),
805
- end_date,
806
- to_currency=self.currency,
807
- ffill_returns=True,
808
- )
809
- # Get raw prices to speed up asset position creation
810
- prices = get_prices(instrument_ids, (start_date - BDay(3)).date(), end_date)
811
- # Instantiate the position iterator with the initial weights
812
- positions = AssetPositionIterator(self, prices=prices)
813
- last_trade_proposal = None
844
+ _, weights = next(self.drift_weights(previous_date, start_date))
845
+ last_order_proposal = None
814
846
  for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
815
847
  to_date = to_date_ts.date()
816
848
  to_is_active = self.is_active_at_date(to_date)
817
849
  logger.info(f"Processing {to_date:%Y-%m-%d}")
818
- if rebalancer and rebalancer.is_valid(to_date):
819
- last_trade_proposal = rebalancer.evaluate_rebalancing(to_date)
820
- # if trade proposal/rebalancing is not approved, we cannot continue the drift
821
- if last_trade_proposal.status != last_trade_proposal.Status.APPROVED:
850
+ order_proposal = None
851
+ try:
852
+ last_returns = self.builder.returns.loc[[to_date_ts], :]
853
+ analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
854
+ drifted_weights = analytic_portfolio.get_next_weights()
855
+ except KeyError: # if no return for that date, we break and continue
856
+ break
857
+ try:
858
+ order_proposal = self.order_proposals.get(
859
+ trade_date=to_date, rebalancing_model__isnull=True, status="CONFIRMED"
860
+ )
861
+ except ObjectDoesNotExist:
862
+ if rebalancer and rebalancer.is_valid(to_date):
863
+ rebalancer.portfolio = self # ensure reference is the same to access cached returns
864
+ effective_portfolio = PortfolioDTO(
865
+ positions=[
866
+ PositionDTO(
867
+ date=to_date,
868
+ underlying_instrument=i,
869
+ weighting=Decimal(w),
870
+ daily_return=Decimal(last_returns.iloc[-1][i]),
871
+ )
872
+ for i, w in weights.items()
873
+ ]
874
+ )
875
+ order_proposal = rebalancer.evaluate_rebalancing(to_date, effective_portfolio=effective_portfolio)
876
+ if order_proposal:
877
+ last_order_proposal = order_proposal
878
+ if stop_at_rebalancing:
822
879
  break
823
- target_portfolio = last_trade_proposal._build_dto().convert_to_portfolio()
824
880
  next_weights = {
825
- underlying_quote_id: float(pos.weighting)
826
- for underlying_quote_id, pos in target_portfolio.positions_map.items()
881
+ trade.underlying_instrument.id: float(trade._target_weight)
882
+ for trade in order_proposal.get_orders()
827
883
  }
884
+ yield to_date, next_weights
828
885
  else:
829
- try:
830
- last_returns = returns.loc[[to_date_ts], :]
831
- analytic_portfolio = AnalyticPortfolio(weights=weights, X=last_returns)
832
- next_weights = analytic_portfolio.get_next_weights()
833
- except KeyError: # if no return for that date, we break and continue
834
- next_weights = weights
835
- if to_is_active:
836
- positions.add((to_date, next_weights))
837
- else:
838
- positions.add(
839
- (to_date, {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()})
840
- ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
841
- break
886
+ next_weights = drifted_weights
887
+ if to_is_active:
888
+ yield to_date, next_weights
889
+ else:
890
+ yield (
891
+ to_date,
892
+ {underlying_quote_id: 0.0 for underlying_quote_id in weights.keys()},
893
+ ) # if we have no return or portfolio is not active anymore, we return an emptied portfolio
894
+ break
842
895
  weights = next_weights
843
- return positions, last_trade_proposal
896
+ return last_order_proposal
844
897
 
845
898
  def propagate_or_update_assets(self, from_date: date, to_date: date):
846
899
  """
@@ -856,10 +909,21 @@ class Portfolio(DeleteToDisableMixin, WBModel):
856
909
  if (
857
910
  not self.is_lookthrough and not is_target_portfolio_imported and self.is_active_at_date(from_date)
858
911
  ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
859
- positions, _ = self.drift_weights(from_date, to_date)
860
- self.bulk_create_positions(
861
- positions, delete_leftovers=True, compute_metrics=True, evaluate_rebalancer=False
862
- )
912
+ self.load_builder_returns(from_date, to_date)
913
+ for pos_date, weights in self.drift_weights(from_date, to_date):
914
+ self.builder.add((pos_date, weights))
915
+ self.builder.bulk_create_positions(delete_leftovers=True)
916
+ self.builder.schedule_change_at_dates()
917
+ self.builder.schedule_metric_computation()
918
+
919
+ def load_builder_returns(self, from_date: date, to_date: date, use_dl: bool = True) -> pd.DataFrame:
920
+ instruments_ids = list(self.get_weights(from_date).keys())
921
+ for tp in self.order_proposals.filter(trade_date__gte=from_date, trade_date__lte=to_date):
922
+ instruments_ids.extend(tp.orders.values_list("underlying_instrument", flat=True))
923
+ self.builder.load_returns(
924
+ set(instruments_ids), (from_date - BDay(1)).date(), (to_date + BDay(1)).date(), use_dl=use_dl
925
+ )
926
+ return self.builder.returns
863
927
 
864
928
  def get_lookthrough_positions(
865
929
  self,
@@ -903,7 +967,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
903
967
  except IndexError:
904
968
  position.portfolio_created = None
905
969
 
906
- setattr(position, "path", path)
970
+ position.path = path
907
971
  position.initial_shares = None
908
972
  if portfolio_total_asset_value and (price_fx_portfolio := position.price * position.currency_fx_rate):
909
973
  position.initial_shares = (position.weighting * portfolio_total_asset_value) / price_fx_portfolio
@@ -926,7 +990,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
926
990
  if self.is_composition:
927
991
  assets = list(self.get_lookthrough_positions(val_date, **kwargs))
928
992
  else:
929
- assets = self.assets.filter(date=val_date)
993
+ assets = list(self.assets.filter(date=val_date))
930
994
  return assets
931
995
 
932
996
  def compute_lookthrough(self, from_date: date, to_date: date | None = None):
@@ -934,18 +998,20 @@ class Portfolio(DeleteToDisableMixin, WBModel):
934
998
  raise ValueError(
935
999
  "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
936
1000
  )
937
- positions = AssetPositionIterator(self)
938
1001
  if not to_date:
939
1002
  to_date = from_date
940
- for from_date in pd.date_range(from_date, to_date, freq="B").date:
941
- logger.info(f"Compute Look-Through for {self} at {from_date}")
1003
+ for val_date in pd.date_range(from_date, to_date, freq="B").date:
1004
+ logger.info(f"Compute Look-Through for {self} at {val_date}")
942
1005
  portfolio_total_asset_value = (
943
- self.primary_portfolio.get_total_asset_under_management(from_date) if not self.only_weighting else None
1006
+ self.primary_portfolio.get_total_asset_under_management(val_date) if not self.only_weighting else None
944
1007
  )
945
- positions.add(
946
- list(self.primary_portfolio.get_lookthrough_positions(from_date, portfolio_total_asset_value)),
1008
+ self.builder.add(
1009
+ list(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value)),
1010
+ infer_underlying_quote_price=True,
947
1011
  )
948
- self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
1012
+ self.builder.bulk_create_positions(delete_leftovers=True)
1013
+ self.builder.schedule_change_at_dates()
1014
+ self.builder.schedule_metric_computation()
949
1015
 
950
1016
  def update_preferred_classification_per_instrument(self):
951
1017
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -1002,63 +1068,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1002
1068
  def get_representation_label_key(cls):
1003
1069
  return "{{name}}"
1004
1070
 
1005
- def bulk_create_positions(
1006
- self,
1007
- positions: AssetPositionIterator,
1008
- delete_leftovers: bool = False,
1009
- force_save: bool = False,
1010
- compute_metrics: bool = True,
1011
- **kwargs,
1012
- ):
1013
- if positions:
1014
- # we need to delete the existing estimated portfolio because otherwise we risk to have existing and not
1015
- # overlapping positions remaining (as they will not be updating by the bulk create). E.g. when someone
1016
- # change completely the trades of a portfolio model and drift it.
1017
-
1018
- dates = positions.get_dates()
1019
- self.assets.filter(date__in=dates, is_estimated=True).delete()
1020
-
1021
- if self.is_tracked or force_save: # if the portfolio is not "tracked", we do no drift weights
1022
- leftover_positions_ids = list(
1023
- self.assets.filter(date__in=dates).values_list("id", flat=True)
1024
- ) # we need to get the ids otherwise the queryset is reevaluated later
1025
- positions_list = list(positions)
1026
- logger.info(
1027
- f"bulk saving {len(positions_list)} positions ({len(leftover_positions_ids)} leftovers) ..."
1028
- )
1029
- objs = AssetPosition.unannotated_objects.bulk_create(
1030
- positions_list,
1031
- update_fields=[
1032
- "weighting",
1033
- "initial_price",
1034
- "initial_currency_fx_rate",
1035
- "initial_shares",
1036
- "currency_fx_rate_instrument_to_usd",
1037
- "currency_fx_rate_portfolio_to_usd",
1038
- "underlying_quote_price",
1039
- "portfolio",
1040
- "portfolio_created",
1041
- "underlying_instrument",
1042
- ],
1043
- unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
1044
- update_conflicts=True,
1045
- batch_size=10000,
1046
- )
1047
- if delete_leftovers:
1048
- objs_ids = list(map(lambda x: x.id, objs))
1049
- leftover_positions_ids = list(filter(lambda i: i not in objs_ids, leftover_positions_ids))
1050
- logger.info(f"deleting {len(leftover_positions_ids)} leftover positions..")
1051
- AssetPosition.objects.filter(id__in=leftover_positions_ids).delete()
1052
- if compute_metrics and self.is_tracked:
1053
- for val_date in dates:
1054
- compute_metrics_as_task.delay(
1055
- val_date,
1056
- basket_id=self.id,
1057
- basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id,
1058
- )
1059
- for update_date, changed_weights in positions.get_weights().items():
1060
- self.change_at_date(update_date, changed_weights=changed_weights, **kwargs)
1061
-
1062
1071
  @classmethod
1063
1072
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
1064
1073
  if isinstance(portfolio_data, int):
@@ -1198,6 +1207,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1198
1207
  index = Index.objects.create(name=self.name, currency=self.currency)
1199
1208
  index.portfolios.all().delete()
1200
1209
  InstrumentPortfolioThroughModel.objects.update_or_create(instrument=index, defaults={"portfolio": self})
1210
+ return index
1201
1211
 
1202
1212
  @classmethod
1203
1213
  def create_model_portfolio(cls, name: str, currency: Currency, with_index: bool = True):
@@ -1211,20 +1221,9 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1211
1221
  return portfolio
1212
1222
 
1213
1223
 
1214
- def default_estimate_net_value(
1215
- val_date: date, instrument: Instrument, weights: dict[int, float] | None = None
1216
- ) -> float | None:
1217
- portfolio: Portfolio = instrument.portfolio
1218
- previous_val_date = (val_date - BDay(1)).date()
1219
- if not weights:
1220
- weights = portfolio.get_weights(previous_val_date)
1221
- # we assume that in t-1 we will have a portfolio (with at least estimate position). If we use the latest position date before val_date, we run into the problem of being able to compute nav at every date
1222
- if weights and (last_price := instrument.get_latest_price(previous_val_date)):
1223
- with suppress(
1224
- IndexError, InvalidAnalyticPortfolio
1225
- ): # we silent any indexerror introduced by no returns for the past days
1226
- analytic_portfolio = portfolio.get_analytic_portfolio(previous_val_date, weights=weights)
1227
- return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1224
+ def default_estimate_net_value(last_price: Decimal, analytic_portfolio: AnalyticPortfolio) -> float | None:
1225
+ with suppress(IndexError, ValueError): # we silent any indexerror introduced by no returns for the past days
1226
+ return analytic_portfolio.get_estimate_net_value(float(last_price.net_value))
1228
1227
 
1229
1228
 
1230
1229
  @receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
@@ -1233,7 +1232,7 @@ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwarg
1233
1232
  not raw
1234
1233
  and created
1235
1234
  and instance.portfolio.is_lookthrough
1236
- and instance.type == PortfolioPortfolioThroughModel.Type.PRIMARY
1235
+ and instance.type == PortfolioPortfolioThroughModel.Type.LOOK_THROUGH
1237
1236
  ):
1238
1237
  with suppress(AssetPosition.DoesNotExist):
1239
1238
  earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
@@ -1258,10 +1257,33 @@ def update_portfolio_after_investable_universe(*args, end_date: date | None = No
1258
1257
  end_date = date.today()
1259
1258
  end_date = (end_date + timedelta(days=1) - BDay(1)).date() # shift in case of business day
1260
1259
  from_date = (end_date - BDay(1)).date()
1260
+ excluded_positions = defaultdict(list)
1261
1261
  for portfolio in Portfolio.tracked_objects.all().to_dependency_iterator(from_date):
1262
1262
  if not portfolio.is_lookthrough:
1263
1263
  try:
1264
1264
  portfolio.propagate_or_update_assets(from_date, end_date)
1265
+ for positions in portfolio.builder.excluded_positions.values():
1266
+ for pos in positions:
1267
+ excluded_positions[pos.underlying_quote].append(portfolio)
1268
+ portfolio.builder.clear()
1265
1269
  except Exception as e:
1266
1270
  logger.error(f"Exception while propagating portfolio assets {portfolio}: {e}")
1267
1271
  portfolio.estimate_net_asset_values(end_date)
1272
+ # if there were excluded positions, we compiled a itemized list of quote per portfolio that got excluded and warn the current portfolio manager
1273
+ if excluded_positions:
1274
+ body = (
1275
+ "<p>While drifting the portfolios, the following quotes got excluded because of missing prices: </p><ul>"
1276
+ )
1277
+ for quote, portfolios in excluded_positions.items():
1278
+ body += f"<li>{quote}</li><p>Impacted portfolios: </p><ul>"
1279
+ for portfolio in portfolios:
1280
+ body += f"<li>{portfolio}</li>"
1281
+ body += "</ul>"
1282
+ body += "</ul> <p>Note: If the quote has simply changed its primary exchange, please use the adjustment tool provided. Otherwise, please contact a system administrator.</p>"
1283
+ for user in User.objects.filter(profile__in=PortfolioRole.portfolio_managers(), is_active=True):
1284
+ send_notification(
1285
+ code="wbportfolio.portfolio.warning",
1286
+ title="Positions were automatically excluded",
1287
+ body=body,
1288
+ user=user,
1289
+ )