wbportfolio 1.44.5__py2.py3-none-any.whl → 1.45.0__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 (315) hide show
  1. wbportfolio/admin/__init__.py +1 -1
  2. wbportfolio/admin/asset.py +2 -1
  3. wbportfolio/admin/custodians.py +1 -0
  4. wbportfolio/admin/indexes.py +15 -0
  5. wbportfolio/admin/portfolio.py +12 -7
  6. wbportfolio/admin/portfolio_relationships.py +1 -0
  7. wbportfolio/admin/product_groups.py +2 -0
  8. wbportfolio/admin/products.py +2 -1
  9. wbportfolio/admin/reconciliations.py +1 -0
  10. wbportfolio/admin/registers.py +1 -0
  11. wbportfolio/admin/roles.py +1 -0
  12. wbportfolio/admin/transactions/__init__.py +1 -0
  13. wbportfolio/admin/transactions/claim.py +1 -0
  14. wbportfolio/admin/transactions/dividends.py +1 -0
  15. wbportfolio/admin/transactions/fees.py +1 -0
  16. wbportfolio/admin/transactions/rebalancing.py +26 -0
  17. wbportfolio/admin/transactions/trades.py +4 -3
  18. wbportfolio/admin/transactions/transactions.py +1 -0
  19. wbportfolio/analysis/claims.py +2 -1
  20. wbportfolio/contrib/company_portfolio/models.py +3 -6
  21. wbportfolio/contrib/company_portfolio/tests/conftest.py +0 -12
  22. wbportfolio/contrib/company_portfolio/tests/test_models.py +1 -0
  23. wbportfolio/defaults/fees/default.py +1 -0
  24. wbportfolio/factories/__init__.py +1 -7
  25. wbportfolio/factories/adjustments.py +1 -0
  26. wbportfolio/factories/assets.py +13 -7
  27. wbportfolio/factories/claim.py +1 -0
  28. wbportfolio/factories/custodians.py +1 -0
  29. wbportfolio/factories/dividends.py +1 -0
  30. wbportfolio/factories/fees.py +1 -0
  31. wbportfolio/factories/indexes.py +1 -0
  32. wbportfolio/factories/portfolio_cash_flow.py +1 -0
  33. wbportfolio/factories/portfolio_cash_targets.py +1 -0
  34. wbportfolio/factories/portfolio_swing_pricings.py +1 -0
  35. wbportfolio/factories/portfolios.py +3 -0
  36. wbportfolio/factories/product_groups.py +1 -0
  37. wbportfolio/factories/products.py +1 -0
  38. wbportfolio/factories/rebalancing.py +23 -0
  39. wbportfolio/factories/reconciliations.py +1 -0
  40. wbportfolio/factories/roles.py +1 -0
  41. wbportfolio/factories/trades.py +1 -0
  42. wbportfolio/factories/transactions.py +1 -0
  43. wbportfolio/fdm/tasks.py +1 -0
  44. wbportfolio/filters/__init__.py +1 -1
  45. wbportfolio/filters/assets.py +8 -9
  46. wbportfolio/filters/assets_and_net_new_money_progression.py +1 -0
  47. wbportfolio/filters/custodians.py +1 -0
  48. wbportfolio/filters/esg.py +1 -0
  49. wbportfolio/filters/performances.py +7 -6
  50. wbportfolio/filters/portfolios.py +21 -1
  51. wbportfolio/filters/positions.py +1 -0
  52. wbportfolio/filters/products.py +1 -0
  53. wbportfolio/filters/roles.py +1 -0
  54. wbportfolio/filters/signals.py +1 -0
  55. wbportfolio/filters/transactions/claim.py +1 -0
  56. wbportfolio/filters/transactions/fees.py +1 -0
  57. wbportfolio/filters/transactions/trades.py +2 -1
  58. wbportfolio/filters/transactions/transactions.py +1 -0
  59. wbportfolio/import_export/backends/ubs/mixin.py +1 -0
  60. wbportfolio/import_export/backends/wbfdm/adjustment.py +1 -0
  61. wbportfolio/import_export/handlers/asset_position.py +11 -13
  62. wbportfolio/import_export/handlers/fees.py +1 -0
  63. wbportfolio/import_export/handlers/portfolio_cash_flow.py +1 -0
  64. wbportfolio/import_export/handlers/trade.py +1 -0
  65. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +1 -0
  66. wbportfolio/import_export/parsers/jpmorgan/fees.py +1 -0
  67. wbportfolio/import_export/parsers/jpmorgan/strategy.py +5 -4
  68. wbportfolio/import_export/parsers/jpmorgan/valuation.py +1 -0
  69. wbportfolio/import_export/parsers/leonteq/customer_trade.py +1 -0
  70. wbportfolio/import_export/parsers/leonteq/equity.py +13 -12
  71. wbportfolio/import_export/parsers/leonteq/fees.py +1 -0
  72. wbportfolio/import_export/parsers/leonteq/trade.py +1 -0
  73. wbportfolio/import_export/parsers/leonteq/valuation.py +1 -0
  74. wbportfolio/import_export/parsers/natixis/customer_trade.py +1 -0
  75. wbportfolio/import_export/parsers/natixis/d1_customer_trade.py +1 -0
  76. wbportfolio/import_export/parsers/natixis/d1_equity.py +3 -2
  77. wbportfolio/import_export/parsers/natixis/d1_fees.py +1 -0
  78. wbportfolio/import_export/parsers/natixis/d1_trade.py +1 -0
  79. wbportfolio/import_export/parsers/natixis/d1_valuation.py +1 -0
  80. wbportfolio/import_export/parsers/natixis/equity.py +5 -5
  81. wbportfolio/import_export/parsers/natixis/trade.py +1 -0
  82. wbportfolio/import_export/parsers/natixis/utils.py +8 -7
  83. wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +1 -0
  84. wbportfolio/import_export/parsers/sg_lux/customer_trade.py +1 -0
  85. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +2 -1
  86. wbportfolio/import_export/parsers/sg_lux/customer_trade_slk.py +2 -1
  87. wbportfolio/import_export/parsers/sg_lux/customer_trade_without_pw.py +1 -0
  88. wbportfolio/import_export/parsers/sg_lux/equity.py +7 -8
  89. wbportfolio/import_export/parsers/sg_lux/portfolio_cash_flow.py +1 -0
  90. wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +1 -0
  91. wbportfolio/import_export/parsers/sg_lux/registers.py +2 -1
  92. wbportfolio/import_export/parsers/societe_generale/customer_trade.py +1 -0
  93. wbportfolio/import_export/parsers/societe_generale/strategy.py +8 -9
  94. wbportfolio/import_export/parsers/societe_generale/valuation.py +1 -0
  95. wbportfolio/import_export/parsers/tellco/equity.py +5 -4
  96. wbportfolio/import_export/parsers/ubs/api/asset_position.py +15 -14
  97. wbportfolio/import_export/parsers/ubs/api/fees.py +1 -0
  98. wbportfolio/import_export/parsers/ubs/customer_trade.py +1 -0
  99. wbportfolio/import_export/parsers/ubs/equity.py +3 -2
  100. wbportfolio/import_export/parsers/ubs/historical_customer_trade.py +1 -0
  101. wbportfolio/import_export/parsers/ubs/valuation.py +1 -0
  102. wbportfolio/import_export/parsers/vontobel/asset_position.py +19 -19
  103. wbportfolio/import_export/parsers/vontobel/customer_trade.py +1 -0
  104. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +1 -0
  105. wbportfolio/import_export/parsers/vontobel/management_fees.py +1 -0
  106. wbportfolio/import_export/parsers/vontobel/performance_fees.py +1 -0
  107. wbportfolio/import_export/parsers/vontobel/trade.py +1 -0
  108. wbportfolio/import_export/parsers/vontobel/valuation_api.py +20 -0
  109. wbportfolio/import_export/resources/assets.py +4 -3
  110. wbportfolio/import_export/resources/trades.py +1 -0
  111. wbportfolio/metric/backends/base.py +1 -0
  112. wbportfolio/metric/backends/portfolio_base.py +1 -0
  113. wbportfolio/metric/backends/portfolio_esg.py +1 -0
  114. wbportfolio/metric/tests/test_portfolio_base.py +1 -0
  115. wbportfolio/migrations/0052_remove_cash_instrument_ptr_and_more.py +1 -131
  116. wbportfolio/migrations/0067_assetposition_unique_asset_position.py +1 -1
  117. wbportfolio/migrations/0070_remove_assetposition_unique_asset_position_and_more.py +1 -1
  118. wbportfolio/migrations/0073_remove_product_price_computation_and_more.py +407 -0
  119. wbportfolio/models/__init__.py +0 -5
  120. wbportfolio/models/adjustments.py +8 -2
  121. wbportfolio/models/asset.py +117 -98
  122. wbportfolio/models/graphs/portfolio.py +144 -0
  123. wbportfolio/models/graphs/utils.py +83 -0
  124. wbportfolio/models/indexes.py +2 -13
  125. wbportfolio/models/mixins/instruments.py +28 -8
  126. wbportfolio/models/portfolio.py +538 -332
  127. wbportfolio/models/portfolio_cash_flow.py +1 -0
  128. wbportfolio/models/portfolio_relationship.py +6 -2
  129. wbportfolio/models/product_groups.py +3 -2
  130. wbportfolio/models/products.py +3 -17
  131. wbportfolio/models/reconciliations/account_reconciliation_lines.py +1 -0
  132. wbportfolio/models/reconciliations/account_reconciliations.py +1 -0
  133. wbportfolio/models/registers.py +1 -0
  134. wbportfolio/models/transactions/__init__.py +1 -0
  135. wbportfolio/models/transactions/claim.py +8 -8
  136. wbportfolio/models/transactions/dividends.py +1 -0
  137. wbportfolio/models/transactions/fees.py +1 -0
  138. wbportfolio/models/transactions/rebalancing.py +153 -0
  139. wbportfolio/models/transactions/trade_proposals.py +153 -155
  140. wbportfolio/models/transactions/trades.py +48 -40
  141. wbportfolio/models/transactions/transactions.py +6 -12
  142. wbportfolio/models/utils.py +1 -0
  143. wbportfolio/pms/analytics/__init__.py +0 -0
  144. wbportfolio/pms/analytics/portfolio.py +28 -0
  145. wbportfolio/pms/trading/handler.py +13 -16
  146. wbportfolio/pms/typing.py +13 -29
  147. wbportfolio/rebalancing/__init__.py +0 -0
  148. wbportfolio/rebalancing/base.py +16 -0
  149. wbportfolio/rebalancing/decorators.py +17 -0
  150. wbportfolio/rebalancing/models/__init__.py +3 -0
  151. wbportfolio/rebalancing/models/composite.py +31 -0
  152. wbportfolio/rebalancing/models/equally_weighted.py +21 -0
  153. wbportfolio/rebalancing/models/model_portfolio.py +35 -0
  154. wbportfolio/reports/monthly_position_report.py +1 -1
  155. wbportfolio/risk_management/backends/accounts.py +7 -6
  156. wbportfolio/risk_management/backends/controversy_portfolio.py +1 -0
  157. wbportfolio/risk_management/backends/exposure_portfolio.py +1 -0
  158. wbportfolio/risk_management/backends/instrument_list_portfolio.py +1 -0
  159. wbportfolio/risk_management/backends/liquidity_risk.py +1 -0
  160. wbportfolio/risk_management/backends/liquidity_stress_instrument.py +1 -0
  161. wbportfolio/risk_management/backends/mixins.py +1 -0
  162. wbportfolio/risk_management/backends/product_integrity.py +6 -1
  163. wbportfolio/risk_management/backends/stop_loss_instrument.py +1 -0
  164. wbportfolio/risk_management/backends/stop_loss_portfolio.py +1 -0
  165. wbportfolio/risk_management/backends/ucits_portfolio.py +1 -0
  166. wbportfolio/risk_management/tests/test_accounts.py +1 -0
  167. wbportfolio/risk_management/tests/test_controversy_portfolio.py +1 -0
  168. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -0
  169. wbportfolio/risk_management/tests/test_instrument_list_portfolio.py +1 -0
  170. wbportfolio/risk_management/tests/test_liquidity_risk.py +1 -0
  171. wbportfolio/risk_management/tests/test_product_integrity.py +1 -0
  172. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +1 -0
  173. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -0
  174. wbportfolio/risk_management/tests/test_ucits_portfolio.py +1 -0
  175. wbportfolio/serializers/__init__.py +5 -5
  176. wbportfolio/serializers/adjustments.py +1 -0
  177. wbportfolio/serializers/assets.py +18 -19
  178. wbportfolio/serializers/custodians.py +1 -0
  179. wbportfolio/serializers/portfolio_cash_flow.py +1 -0
  180. wbportfolio/serializers/portfolio_cash_targets.py +1 -0
  181. wbportfolio/serializers/portfolio_relationship.py +1 -0
  182. wbportfolio/serializers/portfolio_swing_pricing.py +1 -0
  183. wbportfolio/serializers/portfolios.py +61 -40
  184. wbportfolio/serializers/positions.py +1 -0
  185. wbportfolio/serializers/product_group.py +1 -0
  186. wbportfolio/serializers/products.py +4 -7
  187. wbportfolio/serializers/rebalancing.py +57 -0
  188. wbportfolio/serializers/reconciliations.py +2 -1
  189. wbportfolio/serializers/registers.py +1 -0
  190. wbportfolio/serializers/roles.py +1 -0
  191. wbportfolio/serializers/signals.py +10 -15
  192. wbportfolio/serializers/transactions/__init__.py +1 -1
  193. wbportfolio/serializers/transactions/claim.py +1 -0
  194. wbportfolio/serializers/transactions/fees.py +1 -0
  195. wbportfolio/serializers/transactions/trade_proposals.py +85 -0
  196. wbportfolio/serializers/transactions/trades.py +9 -51
  197. wbportfolio/serializers/transactions/transactions.py +4 -3
  198. wbportfolio/tasks.py +1 -78
  199. wbportfolio/tests/conftest.py +6 -13
  200. wbportfolio/tests/models/test_account_reconciliation.py +2 -0
  201. wbportfolio/tests/models/test_assets.py +27 -19
  202. wbportfolio/tests/models/test_customer_trades.py +1 -0
  203. wbportfolio/tests/models/test_imports.py +5 -1
  204. wbportfolio/tests/models/test_merge.py +5 -4
  205. wbportfolio/tests/models/test_portfolio_cash_flow.py +8 -6
  206. wbportfolio/tests/models/test_portfolios.py +594 -154
  207. wbportfolio/tests/models/test_product_groups.py +1 -0
  208. wbportfolio/tests/models/test_products.py +6 -3
  209. wbportfolio/tests/models/test_roles.py +1 -0
  210. wbportfolio/tests/models/test_splits.py +1 -0
  211. wbportfolio/tests/models/transactions/test_claim.py +1 -0
  212. wbportfolio/tests/models/transactions/test_fees.py +1 -0
  213. wbportfolio/tests/models/transactions/test_rebalancing.py +81 -0
  214. wbportfolio/tests/models/transactions/test_trades.py +1 -0
  215. wbportfolio/tests/models/utils.py +1 -0
  216. wbportfolio/tests/pms/__init__.py +0 -0
  217. wbportfolio/tests/pms/test_analytics.py +35 -0
  218. wbportfolio/tests/rebalancing/__init__.py +0 -0
  219. wbportfolio/tests/rebalancing/test_models.py +127 -0
  220. wbportfolio/tests/serializers/test_claims.py +1 -0
  221. wbportfolio/tests/signals.py +1 -7
  222. wbportfolio/tests/tests.py +2 -0
  223. wbportfolio/tests/viewsets/test_assets.py +1 -0
  224. wbportfolio/tests/viewsets/test_performances.py +1 -0
  225. wbportfolio/tests/viewsets/test_products.py +1 -0
  226. wbportfolio/tests/viewsets/transactions/test_claims.py +1 -0
  227. wbportfolio/urls.py +26 -12
  228. wbportfolio/viewsets/__init__.py +2 -5
  229. wbportfolio/viewsets/adjustments.py +1 -0
  230. wbportfolio/viewsets/assets.py +62 -51
  231. wbportfolio/viewsets/assets_and_net_new_money_progression.py +1 -0
  232. wbportfolio/viewsets/charts/assets.py +3 -1
  233. wbportfolio/viewsets/configs/buttons/__init__.py +1 -1
  234. wbportfolio/viewsets/configs/buttons/assets.py +1 -0
  235. wbportfolio/viewsets/configs/buttons/custodians.py +1 -0
  236. wbportfolio/viewsets/configs/buttons/mixins.py +1 -20
  237. wbportfolio/viewsets/configs/buttons/portfolios.py +90 -76
  238. wbportfolio/viewsets/configs/buttons/signals.py +1 -0
  239. wbportfolio/viewsets/configs/buttons/trades.py +1 -0
  240. wbportfolio/viewsets/configs/display/__init__.py +2 -1
  241. wbportfolio/viewsets/configs/display/adjustments.py +1 -0
  242. wbportfolio/viewsets/configs/display/assets.py +7 -6
  243. wbportfolio/viewsets/configs/display/claim.py +1 -0
  244. wbportfolio/viewsets/configs/display/portfolios.py +127 -79
  245. wbportfolio/viewsets/configs/display/product_performance.py +1 -0
  246. wbportfolio/viewsets/configs/display/rebalancing.py +27 -0
  247. wbportfolio/viewsets/configs/display/trade_proposals.py +7 -4
  248. wbportfolio/viewsets/configs/display/trades.py +75 -42
  249. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -1
  250. wbportfolio/viewsets/configs/endpoints/claim.py +1 -0
  251. wbportfolio/viewsets/configs/endpoints/portfolios.py +23 -7
  252. wbportfolio/viewsets/configs/endpoints/rebalancing.py +6 -0
  253. wbportfolio/viewsets/configs/endpoints/reconciliations.py +1 -0
  254. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +1 -0
  255. wbportfolio/viewsets/configs/endpoints/trades.py +1 -0
  256. wbportfolio/viewsets/configs/menu/adjustments.py +1 -0
  257. wbportfolio/viewsets/configs/menu/assets.py +1 -0
  258. wbportfolio/viewsets/configs/menu/fees.py +1 -0
  259. wbportfolio/viewsets/configs/menu/portfolio_cash_flow.py +1 -0
  260. wbportfolio/viewsets/configs/menu/portfolios.py +4 -2
  261. wbportfolio/viewsets/configs/menu/positions.py +1 -0
  262. wbportfolio/viewsets/configs/menu/roles.py +1 -0
  263. wbportfolio/viewsets/configs/menu/transactions.py +1 -0
  264. wbportfolio/viewsets/configs/previews/portfolios.py +1 -6
  265. wbportfolio/viewsets/configs/titles/__init__.py +1 -1
  266. wbportfolio/viewsets/configs/titles/assets.py +1 -0
  267. wbportfolio/viewsets/configs/titles/fees.py +1 -0
  268. wbportfolio/viewsets/configs/titles/instrument_prices.py +1 -0
  269. wbportfolio/viewsets/configs/titles/portfolios.py +13 -11
  270. wbportfolio/viewsets/configs/titles/roles.py +1 -0
  271. wbportfolio/viewsets/configs/titles/trades.py +1 -0
  272. wbportfolio/viewsets/configs/titles/transactions.py +1 -0
  273. wbportfolio/viewsets/custodians.py +1 -0
  274. wbportfolio/viewsets/esg.py +1 -0
  275. wbportfolio/viewsets/mixins.py +1 -0
  276. wbportfolio/viewsets/portfolio_cash_flow.py +1 -0
  277. wbportfolio/viewsets/portfolio_cash_targets.py +1 -0
  278. wbportfolio/viewsets/portfolio_relationship.py +1 -0
  279. wbportfolio/viewsets/portfolio_swing_pricing.py +1 -0
  280. wbportfolio/viewsets/portfolios.py +228 -61
  281. wbportfolio/viewsets/positions.py +3 -2
  282. wbportfolio/viewsets/product_groups.py +1 -0
  283. wbportfolio/viewsets/product_performance.py +1 -0
  284. wbportfolio/viewsets/products.py +1 -0
  285. wbportfolio/viewsets/reconciliations.py +1 -0
  286. wbportfolio/viewsets/registers.py +1 -0
  287. wbportfolio/viewsets/roles.py +1 -0
  288. wbportfolio/viewsets/signals.py +1 -0
  289. wbportfolio/viewsets/transactions/__init__.py +1 -0
  290. wbportfolio/viewsets/transactions/claim.py +2 -1
  291. wbportfolio/viewsets/transactions/fees.py +1 -0
  292. wbportfolio/viewsets/transactions/mixins.py +1 -0
  293. wbportfolio/viewsets/transactions/rebalancing.py +31 -0
  294. wbportfolio/viewsets/transactions/trade_proposals.py +25 -5
  295. wbportfolio/viewsets/transactions/trades.py +16 -9
  296. wbportfolio/viewsets/transactions/transactions.py +1 -0
  297. {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.0.dist-info}/METADATA +4 -1
  298. {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.0.dist-info}/RECORD +301 -288
  299. wbportfolio/admin/synchronization/__init__.py +0 -2
  300. wbportfolio/admin/synchronization/admin.py +0 -114
  301. wbportfolio/admin/synchronization/portfolio_synchronization.py +0 -18
  302. wbportfolio/admin/synchronization/price_computation.py +0 -21
  303. wbportfolio/defaults/portfolio/default_rebalancing.py +0 -45
  304. wbportfolio/factories/pytest_utils.py +0 -121
  305. wbportfolio/factories/synchronization.py +0 -40
  306. wbportfolio/models/synchronization/__init__.py +0 -3
  307. wbportfolio/models/synchronization/portfolio_synchronization.py +0 -292
  308. wbportfolio/models/synchronization/price_computation.py +0 -200
  309. wbportfolio/models/synchronization/synchronization.py +0 -188
  310. wbportfolio/serializers/synchronization.py +0 -18
  311. wbportfolio/tests/models/test_synchronization.py +0 -617
  312. wbportfolio/viewsets/synchronization.py +0 -25
  313. /wbportfolio/{defaults/portfolio → models/graphs}/__init__.py +0 -0
  314. {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.0.dist-info}/WHEEL +0 -0
  315. {wbportfolio-1.44.5.dist-info → wbportfolio-1.45.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,14 @@
1
+ import logging
1
2
  from contextlib import suppress
2
- from datetime import date, datetime, timedelta
3
+ from datetime import date, timedelta
3
4
  from decimal import Decimal
4
5
  from math import isclose
5
- from typing import Any
6
+ from typing import Any, Iterable
6
7
 
7
8
  import numpy as np
8
9
  import pandas as pd
9
10
  from celery import shared_task
11
+ from django.contrib.contenttypes.models import ContentType
10
12
  from django.contrib.postgres.fields import DateRangeField
11
13
  from django.db import models
12
14
  from django.db.models import (
@@ -23,14 +25,20 @@ from django.db.models import (
23
25
  )
24
26
  from django.db.models.signals import post_save
25
27
  from django.dispatch import receiver
28
+ from django.utils import timezone
29
+ from pandas._libs.tslibs.offsets import BDay
26
30
  from psycopg.types.range import DateRange
27
- from wbcore.contrib.currency.models import CurrencyFXRates
31
+ from skfolio.preprocessing import prices_to_returns
32
+ from wbcore.contrib.currency.models import Currency, CurrencyFXRates
28
33
  from wbcore.contrib.notifications.utils import create_notification_type
29
34
  from wbcore.models import WBModel
35
+ from wbcore.utils.importlib import import_from_dotted_path
30
36
  from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
31
- from wbfdm.contrib.metric.dispatch import compute_metrics
32
- from wbfdm.models import Instrument
37
+ from wbfdm.contrib.metric.tasks import compute_metrics_as_task
38
+ from wbfdm.models import Instrument, InstrumentType
33
39
  from wbfdm.models.instruments.instrument_prices import InstrumentPrice
40
+ from wbfdm.signals import investable_universe_updated
41
+
34
42
  from wbportfolio.models.asset import AssetPosition
35
43
  from wbportfolio.models.indexes import Index
36
44
  from wbportfolio.models.portfolio_relationship import (
@@ -38,9 +46,12 @@ from wbportfolio.models.portfolio_relationship import (
38
46
  PortfolioInstrumentPreferredClassificationThroughModel,
39
47
  )
40
48
  from wbportfolio.models.products import Product
49
+ from wbportfolio.pms.analytics.portfolio import Portfolio as AnalyticPortfolio
41
50
  from wbportfolio.pms.typing import Portfolio as PortfolioDTO
42
51
 
43
- from .utils import get_casted_portfolio_instrument
52
+ from . import ProductGroup
53
+
54
+ logger = logging.getLogger("pms")
44
55
 
45
56
 
46
57
  class DefaultPortfolioQueryset(QuerySet):
@@ -48,7 +59,44 @@ class DefaultPortfolioQueryset(QuerySet):
48
59
  """
49
60
  Filter the queryset to get only portfolio invested at the given date
50
61
  """
51
- return self.filter(invested_timespan__startswith__lte=val_date, invested_timespan__endswith__gt=val_date)
62
+ return self.filter(
63
+ (Q(invested_timespan__startswith__lte=val_date) | Q(invested_timespan__startswith__isnull=True))
64
+ & (Q(invested_timespan__endswith__gt=val_date) | Q(invested_timespan__endswith__isnull=True))
65
+ )
66
+
67
+ def to_dependency_iterator(self, val_date: date) -> Iterable["Portfolio"]:
68
+ """
69
+ 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.
70
+ """
71
+ MAX_ITERATIONS: int = (
72
+ 5 # in order to avoid circular dependency and infinite loop, we need to stop recursion at a max depth
73
+ )
74
+ remaining_portfolios = set(self)
75
+
76
+ def _iterator(p, iterator_counter=0):
77
+ iterator_counter += 1
78
+ parent_portfolios = remaining_portfolios & set(
79
+ map(lambda o: o[0], p.get_parent_portfolios(val_date))
80
+ ) # get composition parent portfolios
81
+ dependency_relationships = PortfolioPortfolioThroughModel.objects.filter(
82
+ portfolio=p, dependency_portfolio__in=remaining_portfolios
83
+ ) # get dependency portfolios
84
+ if iterator_counter >= MAX_ITERATIONS or (
85
+ not dependency_relationships.exists() and not bool(parent_portfolios)
86
+ ): # if not dependency portfolio or parent portfolio that remained, then we yield
87
+ remaining_portfolios.remove(p)
88
+ yield p
89
+ else:
90
+ # otherwise, we iterate of the dependency portfolio first
91
+ deps_portfolios = parent_portfolios.union(
92
+ set([r.dependency_portfolio for r in dependency_relationships])
93
+ )
94
+ for deps_p in deps_portfolios:
95
+ yield from _iterator(deps_p, iterator_counter=iterator_counter)
96
+
97
+ while len(remaining_portfolios) > 0:
98
+ portfolio = next(iter(remaining_portfolios))
99
+ yield from _iterator(portfolio)
52
100
 
53
101
 
54
102
  class DefaultPortfolioManager(ActiveObjectManager):
@@ -73,8 +121,6 @@ class PortfolioPortfolioThroughModel(models.Model):
73
121
  class Type(models.TextChoices):
74
122
  PRIMARY = "PRIMARY", "Primary"
75
123
  MODEL = "MODEL", "Model"
76
- BENCHMARK = "BENCHMARK", "Benchmark"
77
- INDEX = "INDEX", "Index"
78
124
  CUSTODIAN = "CUSTODIAN", "Custodian"
79
125
 
80
126
  portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
@@ -83,6 +129,9 @@ class PortfolioPortfolioThroughModel(models.Model):
83
129
  )
84
130
  type = models.CharField(choices=Type.choices, default=Type.PRIMARY, verbose_name="Type")
85
131
 
132
+ def __str__(self):
133
+ return f"{self.portfolio} dependant on {self.dependency_portfolio} ({self.Type[self.type].label})"
134
+
86
135
  class Meta:
87
136
  constraints = [
88
137
  models.UniqueConstraint(fields=["portfolio", "type"], name="unique_primary", condition=Q(type="PRIMARY")),
@@ -126,14 +175,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
126
175
  verbose_name="The portfolios this portfolio depends on",
127
176
  )
128
177
 
129
- portfolio_synchronization = models.ForeignKey(
130
- "wbportfolio.PortfolioSynchronization",
131
- null=True,
132
- blank=True,
133
- on_delete=models.SET_NULL,
134
- related_name="portfolios",
135
- verbose_name="Portfolio Synchronization Method",
136
- )
137
178
  preferred_instrument_classifications = models.ManyToManyField(
138
179
  "wbfdm.Instrument",
139
180
  limit_choices_to=(models.Q(instrument_type__is_classifiable=True) & models.Q(level=0)),
@@ -161,20 +202,27 @@ class Portfolio(DeleteToDisableMixin, WBModel):
161
202
  )
162
203
  is_tracked = models.BooleanField(
163
204
  default=True,
164
- help_text="True if the internal updating mechanism (e.g., Propagation, Synchronization etc...) needs to apply to this portfolio",
205
+ help_text="True if the internal updating mechanism (e.g., Next weights or Look-Through computation, rebalancing etc...) needs to apply to this portfolio",
165
206
  )
166
207
  only_weighting = models.BooleanField(
167
208
  default=False,
168
209
  help_text="Indicates that this portfolio is only utilizing weights and disregards shares, e.g. a model portfolio",
169
210
  )
170
-
171
- last_synchronization = models.DateTimeField(blank=True, null=True, verbose_name="Last Synchronization Date")
211
+ is_lookthrough = models.BooleanField(
212
+ default=False,
213
+ help_text="Indicates that this portfolio is a look-through portfolio",
214
+ )
215
+ is_composition = models.BooleanField(
216
+ default=False, help_text="If true, this portfolio is a composition of other portfolio"
217
+ )
218
+ updated_at = models.DateTimeField(blank=True, null=True, verbose_name="Updated At")
172
219
  bank_accounts = models.ManyToManyField(
173
220
  to="directory.BankingContact",
174
221
  related_name="wbportfolio_portfolios",
175
222
  through="wbportfolio.PortfolioBankAccountThroughModel",
176
223
  blank=True,
177
224
  )
225
+
178
226
  objects = DefaultPortfolioManager()
179
227
  tracked_objects = ActiveTrackedPortfolioManager()
180
228
 
@@ -193,16 +241,28 @@ class Portfolio(DeleteToDisableMixin, WBModel):
193
241
  ).dependency_portfolio
194
242
 
195
243
  @property
196
- def benchmark_portfolio(self):
244
+ def composition_portfolio(self):
197
245
  with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
198
246
  return PortfolioPortfolioThroughModel.objects.get(
199
- portfolio=self, type=PortfolioPortfolioThroughModel.Type.BENCHMARK
247
+ portfolio=self,
248
+ type=PortfolioPortfolioThroughModel.Type.MODEL,
249
+ dependency_portfolio__is_composition=True,
200
250
  ).dependency_portfolio
201
251
 
202
252
  @property
203
253
  def imported_assets(self):
204
254
  return self.assets.filter(is_estimated=False)
205
255
 
256
+ @property
257
+ def pms_instruments(self):
258
+ yield from Product.objects.filter(portfolios=self)
259
+ yield from ProductGroup.objects.filter(portfolios=self)
260
+ yield from Index.objects.filter(portfolios=self)
261
+
262
+ @property
263
+ def can_be_rebalanced(self):
264
+ return self.is_tracked and self.is_manageable and not self.is_lookthrough
265
+
206
266
  def delete(self, **kwargs):
207
267
  super().delete(**kwargs)
208
268
  # We check if for all linked instruments, this portfolio was the last active one (if yes, we disable the instrument)
@@ -212,10 +272,57 @@ class Portfolio(DeleteToDisableMixin, WBModel):
212
272
  instrument.delisted_date = date.today() - timedelta(days=1)
213
273
  instrument.save()
214
274
 
215
- def _build_dto(self, val_date: date, **extra_filter_kwargs) -> PortfolioDTO:
275
+ def _build_dto(self, val_date: date, **extra_kwargs) -> PortfolioDTO:
216
276
  "returns the dto representation of this portfolio at the specified date"
217
277
  return PortfolioDTO(
218
- tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_filter_kwargs)])
278
+ tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_kwargs)]),
279
+ )
280
+
281
+ def get_weights(self, val_date: date) -> dict[int, float]:
282
+ """
283
+ A convenience utility method to returns the portfolio weights for this portfolio as a dictionary (instrument id as key and weights as value)
284
+
285
+ Args:
286
+ val_date: The date at which to return the weights for this portfolio
287
+
288
+ Returns:
289
+ A dictionary containing the weights for this portfolio
290
+ """
291
+ return dict(
292
+ map(
293
+ lambda r: (r[0], float(r[1])),
294
+ self.assets.filter(date=val_date)
295
+ .values("underlying_quote")
296
+ .annotate(sum_weight=Sum("weighting"))
297
+ .values_list("underlying_quote", "sum_weight"),
298
+ )
299
+ )
300
+
301
+ def get_analytic_portfolio(self, val_date: date, with_previous_weights: bool = False) -> AnalyticPortfolio:
302
+ """
303
+ Return the analytic portfolio associated with this portfolio at the given date
304
+
305
+ the analytic portfolio inherit from SKFolio Portfolio and can be used to access all this library methods
306
+ Args:
307
+ val_date: the date to calculate the portfolio for
308
+ with_previous_weights: If true, excludes the previous weights into the analytic portfolio (might be necessary for some metrics)
309
+
310
+ Returns:
311
+ The instantiated analytic portfolio
312
+ """
313
+ weights = self.get_weights(val_date)
314
+ instrument_ids = weights.keys()
315
+ previous_weights = None
316
+ if with_previous_weights:
317
+ if previous_date := self.get_latest_asset_position_date(val_date - timedelta(days=1)):
318
+ previous_weights = self.get_weights(previous_date)
319
+ instrument_ids = previous_weights.keys()
320
+ returns = self.get_returns(instrument_ids, (val_date - BDay(3)).date(), val_date)[0]
321
+ returns = returns.fillna(0) # not sure this is what we want
322
+ return AnalyticPortfolio(
323
+ X=returns,
324
+ weights=weights,
325
+ previous_weights=previous_weights,
219
326
  )
220
327
 
221
328
  def is_invested_at_date(self, val_date: date) -> bool:
@@ -243,22 +350,6 @@ class Portfolio(DeleteToDisableMixin, WBModel):
243
350
  ),
244
351
  ]
245
352
 
246
- @classmethod
247
- def create_model_portfolio(cls, name, currency, portfolio_synchronization=None, index_parameters=dict()):
248
- portfolio = cls.objects.create(
249
- is_manageable=True,
250
- name=name,
251
- currency=currency,
252
- portfolio_synchronization=portfolio_synchronization,
253
- )
254
- if index_parameters:
255
- index = Index.objects.create(name=name, currency=currency, **index_parameters)
256
- index.portfolios.all().delete()
257
- InstrumentPortfolioThroughModel.objects.update_or_create(
258
- instrument=index, defaults={"portfolio": portfolio}
259
- )
260
- return portfolio
261
-
262
353
  def is_active_at_date(self, val_date: date) -> bool:
263
354
  """
264
355
  Return if the base instrument has a total aum greater than 0
@@ -271,7 +362,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
271
362
  )
272
363
  return active_portfolio
273
364
 
274
- def get_aum(self, val_date: date) -> Decimal:
365
+ def get_total_asset_value(self, val_date: date) -> Decimal:
275
366
  """
276
367
  Return the total asset under management of the portfolio at the specified valuation date
277
368
  Args:
@@ -281,7 +372,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
281
372
  """
282
373
  return self.assets.filter(date=val_date).aggregate(s=Sum("total_value_fx_portfolio"))["s"] or Decimal(0.0)
283
374
 
284
- def get_total_value(self, val_date):
375
+ def get_total_asset_under_management(self, val_date):
285
376
  from wbportfolio.models.transactions.trades import Trade
286
377
 
287
378
  trades = Trade.valid_customer_trade_objects.filter(portfolio=self, transaction_date__lte=val_date)
@@ -330,7 +421,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
330
421
  def get_holding(self, val_date, exclude_cash=True, exclude_index=True):
331
422
  qs = self._get_assets(with_cash=not exclude_cash).filter(date=val_date, weighting__gt=0)
332
423
  if exclude_index:
333
- qs = qs.exclude(underlying_security_instrument_type_key="index")
424
+ qs = qs.exclude(underlying_instrument__instrument_type=InstrumentType.INDEX)
334
425
  return (
335
426
  qs.values("underlying_instrument__name")
336
427
  .annotate(total_value_fx_portfolio=Sum("total_value_fx_portfolio"), weighting=Sum("weighting"))
@@ -350,7 +441,8 @@ class Portfolio(DeleteToDisableMixin, WBModel):
350
441
  if exclude_index:
351
442
  # We exclude only index that are not considered as cash. Setting exclude_cash to true convers this case.
352
443
  qs = qs.exclude(
353
- Q(underlying_security_instrument_type_key="index") & Q(underlying_instrument__is_cash=False)
444
+ Q(underlying_instrument__instrument_type=InstrumentType.INDEX)
445
+ & Q(underlying_instrument__is_cash=False)
354
446
  )
355
447
  if extra_filter_parameters:
356
448
  qs = qs.filter(**extra_filter_parameters)
@@ -384,7 +476,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
384
476
  val_date=val_date,
385
477
  exclude_cash=True,
386
478
  exclude_index=True,
387
- extra_filter_parameters={"underlying_security_instrument_type_key": "equity"},
479
+ extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
388
480
  **kwargs,
389
481
  )
390
482
  if not df.empty:
@@ -397,7 +489,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
397
489
  val_date=val_date,
398
490
  exclude_cash=True,
399
491
  exclude_index=True,
400
- extra_filter_parameters={"underlying_security_instrument_type_key": "equity"},
492
+ extra_filter_parameters={"underlying_instrument__instrument_type": InstrumentType.EQUITY},
401
493
  **kwargs,
402
494
  )
403
495
  if not df.empty:
@@ -462,33 +554,49 @@ class Portfolio(DeleteToDisableMixin, WBModel):
462
554
  def get_portfolio_contribution_df(self, start, end, with_cash=True, hedged_currency=None, only_equity=False):
463
555
  qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
464
556
  if only_equity:
465
- qs = qs.filter(underlying_security_instrument_type_key="equity")
557
+ qs = qs.filter(underlying_instrument__instrument_type=InstrumentType.EQUITY)
466
558
  return Portfolio.get_contribution_df(qs, hedged_currency=hedged_currency)
467
559
 
468
560
  def check_related_portfolio_at_date(self, val_date: date, related_portfolio: "Portfolio"):
469
561
  assets = AssetPosition.objects.filter(
470
562
  date=val_date, underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
471
- ).values("underlying_instrument__parent", "shares")
563
+ ).values("underlying_instrument", "shares")
472
564
  assets1 = assets.filter(portfolio=self)
473
565
  assets2 = assets.filter(portfolio=related_portfolio)
474
566
  return assets1.difference(assets2)
475
567
 
568
+ def get_child_portfolios(self, val_date: date) -> set["Portfolio"]:
569
+ child_portfolios = set()
570
+ if pms_instruments := list(self.pms_instruments):
571
+ for parent_portfolio in Portfolio.objects.filter(
572
+ id__in=AssetPosition.objects.filter(date=val_date, underlying_quote__in=pms_instruments).values(
573
+ "portfolio"
574
+ )
575
+ ):
576
+ child_portfolios.add(parent_portfolio)
577
+ return child_portfolios
578
+
579
+ def get_parent_portfolios(self, val_date: date) -> set["Portfolio"]:
580
+ for asset in self.assets.filter(date=val_date, underlying_instrument__portfolios__isnull=False).distinct(
581
+ "underlying_instrument"
582
+ ):
583
+ if portfolio := asset.underlying_instrument.portfolio:
584
+ yield portfolio, asset.weighting
585
+
476
586
  def change_at_date(
477
587
  self,
478
588
  val_date: date,
479
589
  recompute_weighting: bool = False,
480
590
  force_recompute_weighting: bool = False,
481
- synchronize: bool = True,
482
- **sync_kwargs,
591
+ compute_metrics: bool = False,
483
592
  ):
484
- qs = (
485
- self.assets.filter(date=val_date)
486
- .filter(Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False))
487
- .distinct()
593
+ logger.info(f"change at date for {self} at {val_date}")
594
+ qs = self.assets.filter(date=val_date).filter(
595
+ Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False)
488
596
  )
489
597
 
490
598
  # We normalize weight across the portfolio for a given date
491
- if (self.portfolio_synchronization or self.is_manageable or force_recompute_weighting) and qs.exists():
599
+ if (self.is_lookthrough or self.is_manageable or force_recompute_weighting) and qs.exists():
492
600
  total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
493
601
  # We check if this actually necessary
494
602
  # (i.e. if the weight is already summed to 100%, it is already normalized)
@@ -501,283 +609,257 @@ class Portfolio(DeleteToDisableMixin, WBModel):
501
609
  elif total_weighting:
502
610
  asset.weighting = asset.weighting / total_weighting
503
611
  asset.save()
504
- if synchronize:
505
- for dependent_portfolio in self.dependent_portfolios.exclude(id=self.id).distinct():
506
- # Check if the dependent portfolio has a synchronization method and has assets at the specified date
507
- if (synchronization := dependent_portfolio.portfolio_synchronization) and (
508
- dependent_portfolio.assets.filter(date__gte=val_date).exists()
509
- ):
510
- # If this is true, we want to apply the synchronization at every synchronization period
511
- # (scheduled crontab) from val_date to now.
512
- if synchronization.propagate_history:
513
- for _d in synchronization.dates_range(
514
- val_date, dependent_portfolio.assets.latest("date").date, filter_daily=True
515
- ):
516
- synchronization.synchronize_as_task_si(
517
- dependent_portfolio, _d, override_execution_datetime_validity=True
518
- ).apply_async()
519
- # Otherwise, we simply call a unique task for that date
520
- else:
521
- synchronization.synchronize_as_task_si(
522
- dependent_portfolio, val_date, override_execution_datetime_validity=True
523
- ).apply_async()
524
-
525
- # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
526
- for instrument in self.instruments.all():
527
- if price_computation := getattr(
528
- get_casted_portfolio_instrument(instrument), "price_computation", None
529
- ):
530
- inception_date = instrument.inception_date
531
- if isinstance(inception_date, datetime):
532
- inception_date = inception_date.date()
533
-
534
- if isinstance(val_date, datetime):
535
- val_date = val_date.date()
536
-
537
- if inception_date is None or inception_date > val_date:
538
- instrument.inception_date = val_date
539
- instrument.save()
540
- price_computation.compute(instrument, val_date, override_execution_datetime_validity=True)
541
- compute_metrics(val_date, basket=self)
542
-
543
- def propagate_or_update_assets(
544
- self,
545
- from_date: date,
546
- to_date: date,
547
- forward_price: bool | None = True,
548
- base_assets: dict[str, str] | None = None,
549
- delete_existing_assets: bool | None = False,
550
- ):
551
- # we don't propagate on already imported portfolio by default
552
- is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
553
- if not base_assets:
554
- base_assets = dict()
555
612
 
556
- def _get_next_asset_valuation_date(current_asset_valuation_date):
557
- return (current_asset_valuation_date + pd.offsets.BDay(np.busday_count(from_date, to_date))).date()
613
+ # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
614
+ self.estimate_net_asset_values(val_date)
615
+ self.evaluate_rebalancing(val_date)
616
+
617
+ self.updated_at = timezone.now()
618
+ self.save()
619
+
620
+ if compute_metrics:
621
+ compute_metrics_as_task.delay(
622
+ val_date, basket_id=self.id, basket_content_type_id=ContentType.objects.get_for_model(Portfolio).id
623
+ )
624
+ self.handle_controlling_portfolio_change_at_date(val_date)
625
+
626
+ def handle_controlling_portfolio_change_at_date(self, val_date: date):
627
+ for rel in PortfolioPortfolioThroughModel.objects.filter(
628
+ dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY, portfolio__is_lookthrough=True
629
+ ):
630
+ portfolio_total_asset_value = (
631
+ self.get_total_asset_under_management(val_date) if not self.only_weighting else None
632
+ )
633
+ rel.portfolio.compute_lookthrough(val_date, portfolio_total_asset_value=portfolio_total_asset_value)
634
+ for rel in PortfolioPortfolioThroughModel.objects.filter(
635
+ dependency_portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
636
+ ):
637
+ rel.portfolio.evaluate_rebalancing(val_date)
638
+ for dependent_portfolio in self.get_child_portfolios(val_date):
639
+ dependent_portfolio.handle_controlling_portfolio_change_at_date(val_date)
640
+
641
+ def evaluate_rebalancing(self, val_date: date):
642
+ # if the portfolio has an automatic rebalancer and the next business day is suitable with the rebalancer, we create a trade proposal automatically
643
+ next_business_date = (val_date + BDay(1)).date()
644
+
645
+ if hasattr(self, "automatic_rebalancer"):
646
+ logger.info(f"Evaluate Rebalancing for {self} at {next_business_date}")
647
+ if self.automatic_rebalancer.is_valid(next_business_date):
648
+ self.automatic_rebalancer.evaluate_rebalancing(next_business_date)
649
+
650
+ def estimate_net_asset_values(self, val_date: date):
651
+ for instrument in self.pms_instruments:
652
+ if net_asset_value_computation_method_path := instrument.net_asset_value_computation_method_path:
653
+ logger.info(f"Estimate NAV of {val_date:%Y-%m-%d} for instrument {instrument}")
654
+ net_asset_value_computation_method = import_from_dotted_path(net_asset_value_computation_method_path)
655
+ estimated_net_asset_value = net_asset_value_computation_method(val_date, instrument)
656
+ if estimated_net_asset_value is not None:
657
+ InstrumentPrice.objects.update_or_create(
658
+ instrument=instrument,
659
+ date=val_date,
660
+ calculated=True,
661
+ defaults={
662
+ "gross_value": estimated_net_asset_value,
663
+ "net_value": estimated_net_asset_value,
664
+ },
665
+ )
666
+ if (
667
+ val_date == instrument.prices.latest("date").date
668
+ ): # if price date is the latest instrument price date, we recompute the last valuation data
669
+ instrument.update_last_valuation_date()
670
+ instrument.update_last_valuation_date()
671
+
672
+ def get_estimated_portfolio_from_weights(
673
+ self, val_date: date, weights: dict[int, float], prices: dict[int, Decimal] | None = None
674
+ ) -> Iterable[AssetPosition]:
675
+ """
676
+ Given weights and the corresponding instrument price, instantiate asset positions (as AssetPosition object)
677
+
678
+ Args:
679
+ val_date: The positions valuation date
680
+ weights: The positions weights as dictionary (Instrument IDS as key and weights as values)
681
+ prices: The prices associated with each position
682
+
683
+ Returns:
684
+ Yield AssetPosition objects.
685
+ """
686
+ if prices is None:
687
+ prices = dict()
688
+ try:
689
+ currency_fx_rate_portfolio_to_usd = CurrencyFXRates.objects.get(date=val_date, currency=self.currency)
690
+ except CurrencyFXRates.DoesNotExist:
691
+ currency_fx_rate_portfolio_to_usd = None
692
+ for underlying_quote_id, next_weight in weights.items():
693
+ underlying_quote = Instrument.objects.get(id=underlying_quote_id)
694
+ position = AssetPosition(
695
+ underlying_quote=underlying_quote,
696
+ weighting=next_weight,
697
+ date=val_date,
698
+ asset_valuation_date=val_date,
699
+ is_estimated=True,
700
+ portfolio=self,
701
+ currency=underlying_quote.currency,
702
+ currency_fx_rate_portfolio_to_usd=currency_fx_rate_portfolio_to_usd,
703
+ initial_price=prices.get(underlying_quote_id, None),
704
+ initial_currency_fx_rate=None,
705
+ currency_fx_rate_instrument_to_usd=None,
706
+ underlying_quote_price=None,
707
+ underlying_instrument=None,
708
+ )
709
+ position.pre_save()
710
+ yield position
711
+
712
+ def batch_portfolio(self, start_date: date, end_date: date):
713
+ """
714
+ Create the cumulative portfolios between the two given dates and stop at the first rebalancing (if any)
715
+
716
+ Returns: The trade proposal generated by the rebalancing, if any (otherwise None)
717
+ """
718
+ analytic_portfolio = self.get_analytic_portfolio(start_date)
719
+ initial_assets = analytic_portfolio.assets
720
+ positions = []
721
+ next_trade_proposal = None
722
+ rebalancing_date = None
723
+ returns, prices = self.get_returns(initial_assets, (start_date - BDay(3)).date(), end_date, ffill_returns=True)
724
+ for to_date_ts in pd.date_range(start_date + timedelta(days=1), end_date, freq="B"):
725
+ to_date = to_date_ts.date()
726
+ if rebalancer := getattr(self, "automatic_rebalancer", None):
727
+ if rebalancer.is_valid(to_date):
728
+ rebalancing_date = to_date
729
+ break
730
+ # with suppress(IndexError):
731
+ last_returns = returns.loc[[to_date_ts], :]
732
+ next_weights = analytic_portfolio.get_next_weights(last_returns.iloc[-1, :].T)
733
+ positions.extend(
734
+ self.get_estimated_portfolio_from_weights(
735
+ to_date, next_weights, prices.loc[to_date_ts, :].dropna().to_dict()
736
+ )
737
+ )
738
+ analytic_portfolio = AnalyticPortfolio(X=last_returns, weights=next_weights)
739
+
740
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=False)
741
+ if rebalancing_date:
742
+ next_trade_proposal = rebalancer.evaluate_rebalancing(rebalancing_date)
558
743
 
559
- last_fx_date = CurrencyFXRates.objects.filter(date__lte=to_date).latest("date").date
560
- fx_rates = CurrencyFXRates.objects.filter(date=last_fx_date)
561
- assets = self.assets.filter(date=from_date)
744
+ return next_trade_proposal
562
745
 
746
+ def propagate_or_update_assets(self, from_date: date, to_date: date):
747
+ """
748
+ Create a new portfolio at `to_date` based on the portfolio in `from_date`.
749
+
750
+ Args:
751
+ from_date: The date to propagate the portfolio from
752
+ to_date: The date to create the new portfolio at
753
+
754
+ """
563
755
  from_is_active = self.is_active_at_date(from_date)
564
756
  to_is_active = self.is_active_at_date(to_date)
565
- # # We check is the current assets are already stored and if there is no already stored valid assets
566
- # # With this, we ensure that we don't overwrite imported asset position with propagated ones.
567
- # assets_positions_next_day_count = self.assets.filter(date=to_date).count()
568
- if assets.exists() or base_assets:
569
- # Remove already existing assets
570
- if delete_existing_assets:
571
- self.assets.filter(date=to_date).delete()
572
- asset_list = list()
573
- # If base_assets is provided,
574
- # we assume that the portfolio composition is injected by this list of dictionary
575
- if base_assets:
576
- base_assets = (
577
- base_assets
578
- if isinstance(base_assets, dict)
579
- else {asset_id: Decimal(1 / len(base_assets)) for asset_id in base_assets}
580
- )
581
757
 
582
- remaining_base_assets = base_assets.copy()
583
- # Loop over existing assets and construct the propagation assets list
584
- for asset in assets.all():
585
- # if a composition is provided, we ensure that existing assets don't deviate from it
586
- if (base_assets and asset.underlying_instrument.id in base_assets.keys()) or not base_assets:
587
- next_asset_valuation_date = _get_next_asset_valuation_date(asset.asset_valuation_date)
588
- with suppress(ValueError):
589
- asset_list.append(
590
- {
591
- "initial_price": (
592
- asset.initial_price
593
- if asset._price is not None
594
- else asset.underlying_instrument.get_price(from_date)
595
- ),
596
- "asset_valuation_date": next_asset_valuation_date,
597
- "weighting": asset.weighting,
598
- "next_initial_price": asset.underlying_instrument.get_price(next_asset_valuation_date),
599
- "underlying_instrument": asset.underlying_instrument,
600
- "exchange": asset.exchange,
601
- "portfolio": asset.portfolio,
602
- "portfolio_created": asset.portfolio_created,
603
- "currency": asset.currency,
604
- "initial_shares": asset.initial_shares,
605
- }
606
- )
607
- remaining_base_assets.pop(asset.underlying_instrument.id, None)
608
- # We ensure that the propagation assets list contains the proposed composition
609
- for asset_id, weighting in remaining_base_assets.items():
610
- instrument = Instrument.objects.get(id=asset_id)
611
- with suppress(ValueError):
612
- asset_list.append(
613
- {
614
- "underlying_instrument": instrument,
615
- "initial_price": instrument.get_price(from_date),
616
- "next_initial_price": instrument.get_price(to_date),
617
- "asset_valuation_date": to_date,
618
- "initial_shares": None,
619
- "portfolio": self,
620
- "currency": instrument.currency,
621
- "weighting": weighting,
622
- }
758
+ def _parse_position(asset: AssetPosition) -> AssetPosition:
759
+ # function to handle the position modification after instantiation
760
+ if from_is_active and not to_is_active:
761
+ asset.weighting = Decimal(0.0)
762
+ asset.initial_shares = AssetPosition.objects.filter(
763
+ date=from_date, underlying_quote=asset.underlying_quote, portfolio=self
764
+ ).aggregate(sum_shares=Sum("initial_shares"))["sum_shares"]
765
+ return asset
766
+
767
+ # we don't propagate on already imported portfolio by default
768
+ is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
769
+ if (
770
+ self.is_tracked and not self.is_lookthrough and not is_target_portfolio_imported and from_is_active
771
+ ): # we cannot propagate a new portfolio for untracked, or look-through or already imported or inactive portfolios
772
+ analytic_portfolio = self.get_analytic_portfolio(from_date)
773
+ returns, prices = self.get_returns(analytic_portfolio.assets, (from_date - BDay(3)).date(), to_date)
774
+ if not returns.empty:
775
+ weights = analytic_portfolio.get_next_weights(returns.iloc[-1, :].T)
776
+ positions = list(
777
+ map(
778
+ lambda a: _parse_position(a),
779
+ self.get_estimated_portfolio_from_weights(
780
+ to_date, weights, prices.iloc[-1, :].T.dropna().to_dict()
781
+ ),
623
782
  )
783
+ )
784
+ self.bulk_create_positions(positions, delete_leftovers=True, compute_metrics=True)
624
785
 
625
- df = pd.DataFrame(asset_list)
626
- if not df.empty:
627
- df[["initial_price", "weighting", "next_initial_price"]] = df[
628
- ["initial_price", "weighting", "next_initial_price"]
629
- ].astype("float")
630
- idxx = pd.isnull(df["initial_price"]) & ~pd.isnull(df["next_initial_price"])
631
- df.loc[idxx, "initial_price"] = df.loc[idxx, "next_initial_price"]
632
- if forward_price:
633
- idx = pd.isnull(df["next_initial_price"])
634
- df.loc[idx, "next_initial_price"] = df.loc[idx, "initial_price"]
635
- df = df.dropna(axis=0, subset=["next_initial_price", "initial_price"])
636
- # Normalize weight to 100%. Exclude portfolio were sum of weight equals 0 (e.g. short/long portfolio)
637
- if df.weighting.sum() != 0:
638
- df["weighting"] /= df.weighting.sum()
639
- df.loc[:, "perf"] = df.loc[:, "next_initial_price"] / df.loc[:, "initial_price"]
640
- df["contribution"] = df.perf * df.weighting
641
- df.loc[:, "next_weighting"] = df.contribution
642
-
643
- if df.contribution.sum() != 0:
644
- df.loc[:, "next_weighting"] /= df.contribution.sum()
645
-
646
- # Normalize next weighting
647
- if df.next_weighting.sum() != 0:
648
- df.next_weighting /= df.next_weighting.sum()
649
- df = df.replace([np.inf, -np.inf, np.nan], None)
650
- df.loc[(df["next_weighting"] < -1) | (df["next_weighting"] > 1), "next_weighting"] = df.loc[
651
- (df["next_weighting"] < -1) | (df["next_weighting"] > 1), "weighting"
652
- ] # if the next weighting is not including within -1 and 1 range, we default to the initial weighting
653
- if not df.empty:
654
- for row in df.to_dict("records"):
655
- weighting = Decimal(row["next_weighting"]) if row["next_weighting"] else row["weighting"]
656
- if from_is_active and not to_is_active:
657
- weighting = Decimal(0.0)
658
- try:
659
- initial_currency_fx_rate = (
660
- fx_rates.get(currency=self.currency).value
661
- / fx_rates.get(currency=row["currency"]).value
662
- )
663
- except CurrencyFXRates.DoesNotExist:
664
- initial_currency_fx_rate = Decimal(1)
665
- defaults = {
666
- "initial_currency_fx_rate": initial_currency_fx_rate,
667
- "weighting": weighting,
668
- "initial_price": Decimal(row["next_initial_price"]),
669
- "initial_shares": row["initial_shares"],
670
- "asset_valuation_date": row["asset_valuation_date"],
671
- "is_estimated": True,
672
- }
673
- get_parameters = {
674
- "underlying_instrument": row["underlying_instrument"],
675
- "portfolio": self,
676
- "currency": row["currency"],
677
- "date": to_date,
678
- }
679
- if exchange := row.get("exchange", None):
680
- get_parameters["exchange"] = exchange
681
- if portfolio_created := row.get("portfolio_created", None):
682
- get_parameters["portfolio_created"] = portfolio_created
683
- # We check if an asset position already exists and if so, if it is estimated
684
- # (otherwise we don't propagate it)
685
- if _asset := AssetPosition.objects.filter(**get_parameters).first():
686
- _asset.underlying_instrument_price = None # we unset the previously linked underlying instrument price in case it was linked to the wrong underlying price (e.g too early)
687
- if not from_is_active and not to_is_active:
688
- _asset.delete()
689
- elif not is_target_portfolio_imported and _asset.is_estimated:
690
- for k, v in defaults.items():
691
- setattr(_asset, k, v)
692
- _asset.save()
693
- elif from_is_active and to_is_active and not is_target_portfolio_imported:
694
- AssetPosition.objects.create(**get_parameters, **defaults)
695
-
696
- def import_positions_at_date(self, portfolio: PortfolioDTO, val_date: date, post_processing: bool = False):
697
- if not portfolio:
698
- return
699
- left_over_positions = self.assets.filter(date=val_date)
700
-
701
- # We convert the positions into a dataframe in order to handle positions that are considered duplicates
702
- # In that case, we sum up fields such as weighting and shares.
703
- # Position are assumed serialized otherwise the groupby on dataframe can't handle django object
704
- index_columns = ["portfolio_id", "date", "underlying_instrument_id", "portfolio_created_id"]
705
- float_columns = [
706
- "weighting",
707
- "initial_currency_fx_rate",
708
- "initial_shares",
709
- "initial_price",
710
- ]
711
- df = portfolio.to_df().rename(
712
- columns={
713
- "currency_fx_rate": "initial_currency_fx_rate",
714
- "shares": "initial_shares",
715
- "price": "initial_price",
716
- "currency": "currency_id",
717
- "underlying_instrument": "underlying_instrument_id",
718
- "portfolio_created": "portfolio_created_id",
719
- "exchange": "exchange_id",
720
- }
721
- )
722
- df["portfolio_id"] = self.id
723
- df = df[index_columns + float_columns + ["is_estimated", "currency_id"]]
724
- df[float_columns] = df[float_columns].astype("float")
725
- df = df.groupby(index_columns, as_index=False, dropna=False).agg(
726
- {
727
- **{field: "first" for field in df.columns.difference(index_columns + float_columns)},
728
- "weighting": "sum",
729
- "initial_shares": "sum",
730
- "initial_currency_fx_rate": "mean",
731
- "initial_price": "mean",
732
- }
733
- )
734
- df = df.replace([np.inf, -np.inf, np.nan], None)
735
-
736
- for position in df.to_dict("records"):
737
- obj, _ = AssetPosition.unannotated_objects.update_or_create(
738
- portfolio_id=position["portfolio_id"],
739
- date=position["date"],
740
- underlying_instrument_id=position["underlying_instrument_id"],
741
- portfolio_created_id=position["portfolio_created_id"],
742
- defaults=position,
743
- )
744
- left_over_positions = left_over_positions.exclude(id=obj.id)
745
- left_over_positions.delete()
746
- if post_processing:
747
- trigger_portfolio_change_as_task.delay(self.id, val_date)
748
-
749
- def resynchronize_history(self, from_date: date, to_date: date, instrument: Instrument | None = None):
750
- if (synchronisation_method := self.portfolio_synchronization) and self.assets.exists():
751
- if not from_date:
752
- from_date = self.assets.earliest("date").date
753
- if not to_date:
754
- to_date = self.assets.latest("date").date
755
- # loop over every week day and trigger synchronization task in order
756
- if to_date <= from_date:
757
- raise ValueError("bound needs to be valid")
758
- for sync_datetime in synchronisation_method.dates_range(from_date, to_date, filter_daily=True):
759
- synchronisation_method.synchronize(
760
- self, sync_datetime.date(), override_execution_datetime_validity=True
786
+ def get_lookthrough_positions(
787
+ self,
788
+ sync_date: date,
789
+ portfolio_total_asset_value: Decimal | None = None,
790
+ with_intermediary_position: bool = False,
791
+ ):
792
+ """Recursively calculates the look-through position for a portfolio
793
+
794
+ Arguments:
795
+ sync_date {datetime.date} -- The date on which the assets will be computed
796
+ portfolio_total_value: {Decimal} -- The total value of the portfolio (needed to compute initial shares)
797
+ """
798
+
799
+ def _crawl_portfolio(
800
+ parent_portfolio,
801
+ adjusted_weighting,
802
+ adjusted_currency_fx_rate,
803
+ adjusted_is_estimated,
804
+ portfolio_created=None,
805
+ ):
806
+ for position in parent_portfolio.assets.filter(date=sync_date):
807
+ position.id = None
808
+ position.weighting = adjusted_weighting * position.weighting
809
+ position.initial_currency_fx_rate = adjusted_currency_fx_rate * position.currency_fx_rate
810
+ position.is_estimated = (adjusted_is_estimated or position.is_estimated) and not (
811
+ position.weighting == 1.0
761
812
  )
762
- if instrument:
763
- price_computation_method = None
764
- try:
765
- price_computation_method = Product.objects.get(id=instrument.id).price_computation
766
- except Product.DoesNotExist:
767
- with suppress(Index.DoesNotExist):
768
- price_computation_method = Index.objects.get(id=instrument.id).price_computation
769
- if price_computation_method and instrument.prices.exists():
770
- if to_date <= from_date:
771
- raise ValueError("bound needs to be valid")
772
- if not from_date:
773
- from_date = instrument.prices.earliest("date").date
774
- if not to_date:
775
- to_date = instrument.prices.latest("date").date
776
- # loop over every week day and trigger synchronization task in order
777
- for sync_datetime in price_computation_method.dates_range(from_date, to_date, filter_daily=True):
778
- price_computation_method.compute(
779
- instrument, sync_datetime.date(), override_execution_datetime_validity=True
813
+ position.portfolio_created = portfolio_created
814
+ position.parent_portfolio = parent_portfolio
815
+ position.initial_shares = None
816
+ if portfolio_total_asset_value:
817
+ position.initial_shares = (position.weighting * portfolio_total_asset_value) / (
818
+ position.price * position.currency_fx_rate
819
+ )
820
+ if child_portfolio := position.underlying_quote.primary_portfolio:
821
+ if with_intermediary_position:
822
+ yield position
823
+ yield from _crawl_portfolio(
824
+ child_portfolio,
825
+ position.weighting,
826
+ position.currency_fx_rate,
827
+ position.is_estimated,
828
+ portfolio_created=child_portfolio,
780
829
  )
830
+ elif position.weighting: # we do not yield postion with weight 0 because of issue with certain multi-thematic portfolios which contain duplicates
831
+ yield position
832
+
833
+ yield from _crawl_portfolio(self, Decimal(1.0), Decimal(1.0), False)
834
+
835
+ def get_positions(self, val_date: date, **kwargs) -> Iterable[AssetPosition]:
836
+ if self.is_composition:
837
+ assets = list(self.get_lookthrough_positions(val_date, **kwargs))
838
+ else:
839
+ assets = self.assets.filter(date=val_date)
840
+ return assets
841
+
842
+ def compute_lookthrough(self, sync_date: date, portfolio_total_asset_value: Decimal | None = None):
843
+ if not self.primary_portfolio or not self.is_lookthrough:
844
+ raise ValueError(
845
+ "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
846
+ )
847
+ logger.info(f"Compute Look-Through for {self} at {sync_date}")
848
+ positions = self.primary_portfolio.get_lookthrough_positions(sync_date, portfolio_total_asset_value)
849
+ self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=True)
850
+
851
+ def batch_recompute_lookthrough(self, from_date: date, to_date: date):
852
+ if not self.primary_portfolio or not self.is_lookthrough:
853
+ raise ValueError(
854
+ "Lookthrough position can only be computed on lookthrough portfolio with a primary portfolio"
855
+ )
856
+ positions = []
857
+ for val_date in pd.date_range(from_date, to_date, freq="B").date:
858
+ portfolio_total_asset_value = (
859
+ self.get_total_asset_under_management(val_date) if not self.only_weighting else None
860
+ )
861
+ positions.extend(self.primary_portfolio.get_lookthrough_positions(val_date, portfolio_total_asset_value))
862
+ self.bulk_create_positions(list(positions), delete_leftovers=True, compute_metrics=False)
781
863
 
782
864
  def update_preferred_classification_per_instrument(self):
783
865
  # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
@@ -834,6 +916,39 @@ class Portfolio(DeleteToDisableMixin, WBModel):
834
916
  def get_representation_label_key(cls):
835
917
  return "{{name}}"
836
918
 
919
+ def bulk_create_positions(self, positions: list[AssetPosition], delete_leftovers: bool = False, **kwargs):
920
+ if positions:
921
+ update_dates = set()
922
+ for position in positions:
923
+ position.portfolio = self
924
+ update_dates.add(position.date)
925
+ self.assets.filter(date__in=update_dates, is_estimated=True).delete()
926
+ leftover_positions = self.assets.filter(date__in=update_dates).all()
927
+ objs = AssetPosition.objects.bulk_create(
928
+ positions,
929
+ update_fields=[
930
+ "weighting",
931
+ "initial_price",
932
+ "initial_currency_fx_rate",
933
+ "initial_shares",
934
+ "currency_fx_rate_instrument_to_usd",
935
+ "currency_fx_rate_portfolio_to_usd",
936
+ "underlying_quote_price",
937
+ "is_estimated",
938
+ "portfolio",
939
+ "portfolio_created",
940
+ "underlying_instrument",
941
+ ],
942
+ unique_fields=["portfolio", "date", "underlying_quote", "portfolio_created"],
943
+ update_conflicts=True,
944
+ )
945
+ if delete_leftovers:
946
+ for leftover_position in leftover_positions:
947
+ if leftover_position not in objs: # this works because __eq__ of a django model use the id field
948
+ leftover_position.delete()
949
+ for update_date in sorted(update_dates):
950
+ self.change_at_date(update_date, **kwargs)
951
+
837
952
  @classmethod
838
953
  def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
839
954
  if isinstance(portfolio_data, int):
@@ -846,12 +961,48 @@ class Portfolio(DeleteToDisableMixin, WBModel):
846
961
  def check_share_diff(self, val_date: date) -> bool:
847
962
  return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
848
963
 
964
+ def get_returns(
965
+ self, instruments: Iterable, from_date: date, to_date: date, ffill_returns: bool = True
966
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
967
+ """
968
+ Utility methods to get instrument returns for a given date range
969
+
970
+ Args:
971
+ instruments: instrument to get the returns from
972
+ from_date: date range lower bound
973
+ to_date: date range upper bound
974
+
975
+ Returns:
976
+ Return a tuple of the returns and the last prices series for conveniance
977
+ """
978
+ prices = InstrumentPrice.objects.filter(
979
+ instrument__in=instruments, date__gte=from_date, date__lte=to_date
980
+ ).annotate(
981
+ # fx_rate=CurrencyFXRates.get_fx_rates_subquery_for_two_currencies("date", "instrument__currency", self.currency),
982
+ price_fx_portfolio=F("net_value") # * F("net_value")
983
+ )
984
+ prices_df = (
985
+ pd.DataFrame(
986
+ prices.filter_only_valid_prices().values_list("instrument", "price_fx_portfolio", "date"),
987
+ columns=["instrument", "price_fx_portfolio", "date"],
988
+ )
989
+ .pivot_table(index="date", values="price_fx_portfolio", columns="instrument")
990
+ .astype(float)
991
+ .sort_index()
992
+ )
993
+ ts = pd.bdate_range(from_date, to_date, freq="B")
994
+ prices_df = prices_df.reindex(ts)
995
+ if ffill_returns:
996
+ prices_df = prices_df.ffill()
997
+ returns = prices_to_returns(prices_df, drop_inceptions_nan=False, fill_nan=ffill_returns)
998
+ return returns.replace([np.inf, -np.inf, np.nan], 0), prices_df
999
+
849
1000
  @classmethod
850
1001
  def get_contribution_df(
851
1002
  cls,
852
1003
  qs,
853
1004
  need_normalize=False,
854
- groupby_label_id="underlying_security",
1005
+ groupby_label_id="underlying_instrument",
855
1006
  groubpy_label_title="underlying_instrument__name_repr",
856
1007
  currency_fx_rate_label="currency_fx_rate",
857
1008
  hedged_currency=None,
@@ -875,7 +1026,7 @@ class Portfolio(DeleteToDisableMixin, WBModel):
875
1026
  ),
876
1027
  ).select_related("underlying_instrument")
877
1028
  df = pd.DataFrame(
878
- qs.values(
1029
+ qs.values_list(
879
1030
  "date",
880
1031
  "price",
881
1032
  "coalesce_currency_fx_rate",
@@ -1007,10 +1158,43 @@ class Portfolio(DeleteToDisableMixin, WBModel):
1007
1158
  return res.replace([np.inf, -np.inf, np.nan], 0)
1008
1159
  return pd.DataFrame()
1009
1160
 
1161
+ def get_or_create_index(self):
1162
+ index = Index.objects.create(name=self.name, currency=self.currency)
1163
+ index.portfolios.all().delete()
1164
+ InstrumentPortfolioThroughModel.objects.update_or_create(instrument=index, defaults={"portfolio": self})
1165
+
1166
+ @classmethod
1167
+ def create_model_portfolio(cls, name: str, currency: Currency, with_index: bool = True):
1168
+ portfolio = cls.objects.create(
1169
+ is_manageable=True,
1170
+ name=name,
1171
+ currency=currency,
1172
+ )
1173
+ if with_index:
1174
+ portfolio.get_or_create_index()
1175
+ return portfolio
1176
+
1177
+
1178
+ def default_estimate_net_value(val_date: date, instrument: Instrument) -> float | None:
1179
+ portfolio = instrument.portfolio
1180
+ if (
1181
+ previous_date := portfolio.get_latest_asset_position_date(val_date - BDay(1), with_estimated=True)
1182
+ ) and portfolio.assets.filter(date=val_date).exists():
1183
+ analytic_portfolio = portfolio.get_analytic_portfolio(val_date, with_previous_weights=True)
1184
+ with suppress(InstrumentPrice.DoesNotExist, IndexError):
1185
+ if not instrument.prices.filter(date__lte=previous_date).exists():
1186
+ previous_net_asset_value = instrument.issue_price
1187
+ else:
1188
+ previous_net_asset_value = (
1189
+ InstrumentPrice.objects.filter_only_valid_prices()
1190
+ .get(instrument=instrument, date=previous_date)
1191
+ .net_value
1192
+ )
1193
+ return analytic_portfolio.get_estimate_net_value(float(previous_net_asset_value))
1194
+
1010
1195
 
1011
1196
  @receiver(post_save, sender="wbportfolio.Product")
1012
1197
  @receiver(post_save, sender="wbportfolio.ProductGroup")
1013
- @receiver(post_save, sender="wbportfolio.Index")
1014
1198
  def post_product_creation(sender, instance, created, raw, **kwargs):
1015
1199
  if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
1016
1200
  portfolio = Portfolio.objects.create(
@@ -1021,11 +1205,19 @@ def post_product_creation(sender, instance, created, raw, **kwargs):
1021
1205
  InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
1022
1206
 
1023
1207
 
1024
- @shared_task(queue="portfolio")
1025
- def resynchronize_history_as_task(portfolio_id: int, from_date: date, to_date: date, instrument_id: int | None = None):
1026
- portfolio = Portfolio.objects.get(id=portfolio_id)
1027
- instrument = Instrument.objects.get(id=instrument_id) if instrument_id else None
1028
- portfolio.resynchronize_history(from_date, to_date, instrument=instrument)
1208
+ @receiver(post_save, sender="wbportfolio.PortfolioPortfolioThroughModel")
1209
+ def post_portfolio_relationship_creation(sender, instance, created, raw, **kwargs):
1210
+ if (
1211
+ not raw
1212
+ and created
1213
+ and instance.portfolio.is_lookthrough
1214
+ and instance.type == PortfolioPortfolioThroughModel.Type.PRIMARY
1215
+ ):
1216
+ with suppress(AssetPosition.DoesNotExist):
1217
+ earliest_primary_position_date = instance.dependency_portfolio.assets.earliest("date").date
1218
+ batch_recompute_lookthrough_as_task.delay(
1219
+ instance.portfolio.id, earliest_primary_position_date, date.today()
1220
+ )
1029
1221
 
1030
1222
 
1031
1223
  @shared_task(queue="portfolio")
@@ -1035,6 +1227,20 @@ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
1035
1227
 
1036
1228
 
1037
1229
  @shared_task(queue="portfolio")
1038
- def propagate_or_update_portfolio_assets_as_task(portfolio_id, from_date, to_date, **kwargs):
1230
+ def batch_recompute_lookthrough_as_task(portfolio_id: int, start: date, end: date):
1039
1231
  portfolio = Portfolio.objects.get(id=portfolio_id)
1040
- portfolio.propagate_or_update_assets(from_date, to_date, **kwargs)
1232
+ portfolio.batch_recompute_lookthrough(start, end)
1233
+
1234
+
1235
+ @receiver(investable_universe_updated, sender="wbfdm.Instrument")
1236
+ def update_portfolio_after_investable_universe(*args, end_date: date | None = None, **kwargs):
1237
+ if not end_date:
1238
+ end_date = (date.today() - BDay(1)).date()
1239
+ from_date = (end_date - BDay(1)).date()
1240
+ for portfolio in Portfolio.tracked_objects.filter(is_lookthrough=False).to_dependency_iterator(from_date):
1241
+ logger.info(f"computing next weight for {portfolio} from {from_date:%Y-%m-%d} to {end_date:%Y-%m-%d}")
1242
+ try:
1243
+ portfolio.propagate_or_update_assets(from_date, end_date)
1244
+ except Exception as e:
1245
+ logger.error(f"Exception while propagating portfolio assets {portfolio}: {e}")
1246
+ portfolio.estimate_net_asset_values(end_date)