wbportfolio 2.2.1__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 (486) hide show
  1. wbportfolio/__init__.py +1 -0
  2. wbportfolio/admin/__init__.py +12 -0
  3. wbportfolio/admin/asset.py +47 -0
  4. wbportfolio/admin/custodians.py +9 -0
  5. wbportfolio/admin/portfolio.py +127 -0
  6. wbportfolio/admin/portfolio_relationships.py +22 -0
  7. wbportfolio/admin/product_groups.py +42 -0
  8. wbportfolio/admin/products.py +80 -0
  9. wbportfolio/admin/reconciliations.py +14 -0
  10. wbportfolio/admin/registers.py +17 -0
  11. wbportfolio/admin/roles.py +19 -0
  12. wbportfolio/admin/synchronization/__init__.py +2 -0
  13. wbportfolio/admin/synchronization/admin.py +114 -0
  14. wbportfolio/admin/synchronization/portfolio_synchronization.py +18 -0
  15. wbportfolio/admin/synchronization/price_computation.py +21 -0
  16. wbportfolio/admin/transactions/__init__.py +5 -0
  17. wbportfolio/admin/transactions/claim.py +16 -0
  18. wbportfolio/admin/transactions/dividends.py +14 -0
  19. wbportfolio/admin/transactions/fees.py +35 -0
  20. wbportfolio/admin/transactions/trades.py +49 -0
  21. wbportfolio/admin/transactions/transactions.py +37 -0
  22. wbportfolio/analysis/__init__.py +0 -0
  23. wbportfolio/analysis/claims.py +235 -0
  24. wbportfolio/apps.py +5 -0
  25. wbportfolio/contrib/__init__.py +0 -0
  26. wbportfolio/contrib/company_portfolio/__init__.py +0 -0
  27. wbportfolio/contrib/company_portfolio/admin.py +28 -0
  28. wbportfolio/contrib/company_portfolio/apps.py +29 -0
  29. wbportfolio/contrib/company_portfolio/configs/__init__.py +3 -0
  30. wbportfolio/contrib/company_portfolio/configs/display.py +182 -0
  31. wbportfolio/contrib/company_portfolio/configs/endpoints.py +34 -0
  32. wbportfolio/contrib/company_portfolio/configs/previews.py +37 -0
  33. wbportfolio/contrib/company_portfolio/constants.py +1 -0
  34. wbportfolio/contrib/company_portfolio/dynamic_preferences_registry.py +87 -0
  35. wbportfolio/contrib/company_portfolio/factories.py +32 -0
  36. wbportfolio/contrib/company_portfolio/filters.py +127 -0
  37. wbportfolio/contrib/company_portfolio/management.py +19 -0
  38. wbportfolio/contrib/company_portfolio/migrations/0001_initial.py +214 -0
  39. wbportfolio/contrib/company_portfolio/migrations/__init__.py +0 -0
  40. wbportfolio/contrib/company_portfolio/models.py +334 -0
  41. wbportfolio/contrib/company_portfolio/scripts.py +76 -0
  42. wbportfolio/contrib/company_portfolio/serializers.py +303 -0
  43. wbportfolio/contrib/company_portfolio/tasks.py +19 -0
  44. wbportfolio/contrib/company_portfolio/tests/__init__.py +0 -0
  45. wbportfolio/contrib/company_portfolio/tests/conftest.py +161 -0
  46. wbportfolio/contrib/company_portfolio/tests/test_models.py +161 -0
  47. wbportfolio/contrib/company_portfolio/urls.py +29 -0
  48. wbportfolio/contrib/company_portfolio/viewsets.py +195 -0
  49. wbportfolio/defaults/__init__.py +0 -0
  50. wbportfolio/defaults/fees/__init__.py +0 -0
  51. wbportfolio/defaults/fees/default.py +92 -0
  52. wbportfolio/defaults/portfolio/__init__.py +0 -0
  53. wbportfolio/defaults/portfolio/default_rebalancing.py +45 -0
  54. wbportfolio/dynamic_preferences_registry.py +58 -0
  55. wbportfolio/factories/__init__.py +35 -0
  56. wbportfolio/factories/adjustments.py +17 -0
  57. wbportfolio/factories/assets.py +75 -0
  58. wbportfolio/factories/claim.py +39 -0
  59. wbportfolio/factories/custodians.py +11 -0
  60. wbportfolio/factories/dividends.py +14 -0
  61. wbportfolio/factories/fees.py +15 -0
  62. wbportfolio/factories/indexes.py +17 -0
  63. wbportfolio/factories/portfolio_cash_flow.py +20 -0
  64. wbportfolio/factories/portfolio_cash_targets.py +15 -0
  65. wbportfolio/factories/portfolio_swing_pricings.py +15 -0
  66. wbportfolio/factories/portfolios.py +59 -0
  67. wbportfolio/factories/product_groups.py +28 -0
  68. wbportfolio/factories/products.py +56 -0
  69. wbportfolio/factories/pytest_utils.py +121 -0
  70. wbportfolio/factories/reconciliations.py +23 -0
  71. wbportfolio/factories/roles.py +20 -0
  72. wbportfolio/factories/synchronization.py +40 -0
  73. wbportfolio/factories/trades.py +35 -0
  74. wbportfolio/factories/transactions.py +21 -0
  75. wbportfolio/fdm/__init__.py +0 -0
  76. wbportfolio/fdm/tasks.py +12 -0
  77. wbportfolio/filters/__init__.py +32 -0
  78. wbportfolio/filters/assets.py +485 -0
  79. wbportfolio/filters/assets_and_net_new_money_progression.py +42 -0
  80. wbportfolio/filters/custodians.py +10 -0
  81. wbportfolio/filters/esg.py +22 -0
  82. wbportfolio/filters/performances.py +171 -0
  83. wbportfolio/filters/portfolios.py +24 -0
  84. wbportfolio/filters/positions.py +178 -0
  85. wbportfolio/filters/products.py +157 -0
  86. wbportfolio/filters/roles.py +26 -0
  87. wbportfolio/filters/signals.py +92 -0
  88. wbportfolio/filters/transactions/__init__.py +20 -0
  89. wbportfolio/filters/transactions/claim.py +394 -0
  90. wbportfolio/filters/transactions/fees.py +66 -0
  91. wbportfolio/filters/transactions/trades.py +224 -0
  92. wbportfolio/filters/transactions/transactions.py +98 -0
  93. wbportfolio/import_export/__init__.py +0 -0
  94. wbportfolio/import_export/backends/__init__.py +2 -0
  95. wbportfolio/import_export/backends/ubs/__init__.py +3 -0
  96. wbportfolio/import_export/backends/ubs/asset_position.py +45 -0
  97. wbportfolio/import_export/backends/ubs/fees.py +63 -0
  98. wbportfolio/import_export/backends/ubs/instrument_price.py +44 -0
  99. wbportfolio/import_export/backends/ubs/mixin.py +15 -0
  100. wbportfolio/import_export/backends/utils.py +58 -0
  101. wbportfolio/import_export/backends/wbfdm/__init__.py +2 -0
  102. wbportfolio/import_export/backends/wbfdm/adjustment.py +50 -0
  103. wbportfolio/import_export/backends/wbfdm/dividend.py +16 -0
  104. wbportfolio/import_export/backends/wbfdm/mixin.py +15 -0
  105. wbportfolio/import_export/handlers/__init__.py +0 -0
  106. wbportfolio/import_export/handlers/adjustment.py +39 -0
  107. wbportfolio/import_export/handlers/asset_position.py +167 -0
  108. wbportfolio/import_export/handlers/dividend.py +80 -0
  109. wbportfolio/import_export/handlers/fees.py +58 -0
  110. wbportfolio/import_export/handlers/portfolio_cash_flow.py +57 -0
  111. wbportfolio/import_export/handlers/register.py +43 -0
  112. wbportfolio/import_export/handlers/trade.py +191 -0
  113. wbportfolio/import_export/parsers/__init__.py +0 -0
  114. wbportfolio/import_export/parsers/default_mapping.py +30 -0
  115. wbportfolio/import_export/parsers/jpmorgan/__init__.py +0 -0
  116. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +63 -0
  117. wbportfolio/import_export/parsers/jpmorgan/fees.py +64 -0
  118. wbportfolio/import_export/parsers/jpmorgan/strategy.py +116 -0
  119. wbportfolio/import_export/parsers/jpmorgan/valuation.py +41 -0
  120. wbportfolio/import_export/parsers/leonteq/__init__.py +0 -0
  121. wbportfolio/import_export/parsers/leonteq/customer_trade.py +47 -0
  122. wbportfolio/import_export/parsers/leonteq/equity.py +81 -0
  123. wbportfolio/import_export/parsers/leonteq/fees.py +70 -0
  124. wbportfolio/import_export/parsers/leonteq/trade.py +94 -0
  125. wbportfolio/import_export/parsers/leonteq/valuation.py +39 -0
  126. wbportfolio/import_export/parsers/natixis/__init__.py +0 -0
  127. wbportfolio/import_export/parsers/natixis/customer_trade.py +62 -0
  128. wbportfolio/import_export/parsers/natixis/d1_customer_trade.py +66 -0
  129. wbportfolio/import_export/parsers/natixis/d1_equity.py +80 -0
  130. wbportfolio/import_export/parsers/natixis/d1_fees.py +58 -0
  131. wbportfolio/import_export/parsers/natixis/d1_trade.py +70 -0
  132. wbportfolio/import_export/parsers/natixis/d1_valuation.py +41 -0
  133. wbportfolio/import_export/parsers/natixis/dividend.py +53 -0
  134. wbportfolio/import_export/parsers/natixis/equity.py +60 -0
  135. wbportfolio/import_export/parsers/natixis/fees.py +53 -0
  136. wbportfolio/import_export/parsers/natixis/trade.py +63 -0
  137. wbportfolio/import_export/parsers/natixis/utils.py +76 -0
  138. wbportfolio/import_export/parsers/natixis/valuation.py +46 -0
  139. wbportfolio/import_export/parsers/refinitiv/__init__.py +0 -0
  140. wbportfolio/import_export/parsers/refinitiv/adjustment.py +24 -0
  141. wbportfolio/import_export/parsers/sg_lux/__init__.py +0 -0
  142. wbportfolio/import_export/parsers/sg_lux/custodian_positions.py +70 -0
  143. wbportfolio/import_export/parsers/sg_lux/customer_trade.py +75 -0
  144. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +140 -0
  145. wbportfolio/import_export/parsers/sg_lux/customer_trade_slk.py +80 -0
  146. wbportfolio/import_export/parsers/sg_lux/customer_trade_without_pw.py +57 -0
  147. wbportfolio/import_export/parsers/sg_lux/equity.py +137 -0
  148. wbportfolio/import_export/parsers/sg_lux/fees.py +56 -0
  149. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +51 -0
  150. wbportfolio/import_export/parsers/sg_lux/portfolio_cash_flow.py +29 -0
  151. wbportfolio/import_export/parsers/sg_lux/portfolio_future_cash_flow.py +36 -0
  152. wbportfolio/import_export/parsers/sg_lux/registers.py +210 -0
  153. wbportfolio/import_export/parsers/sg_lux/sylk.py +248 -0
  154. wbportfolio/import_export/parsers/sg_lux/utils.py +36 -0
  155. wbportfolio/import_export/parsers/sg_lux/valuation.py +53 -0
  156. wbportfolio/import_export/parsers/societe_generale/__init__.py +0 -0
  157. wbportfolio/import_export/parsers/societe_generale/customer_trade.py +54 -0
  158. wbportfolio/import_export/parsers/societe_generale/strategy.py +94 -0
  159. wbportfolio/import_export/parsers/societe_generale/valuation.py +37 -0
  160. wbportfolio/import_export/parsers/tellco/__init__.py +0 -0
  161. wbportfolio/import_export/parsers/tellco/customer_trade.py +64 -0
  162. wbportfolio/import_export/parsers/tellco/equity.py +86 -0
  163. wbportfolio/import_export/parsers/tellco/valuation.py +52 -0
  164. wbportfolio/import_export/parsers/ubs/__init__.py +0 -0
  165. wbportfolio/import_export/parsers/ubs/api/__init__.py +0 -0
  166. wbportfolio/import_export/parsers/ubs/api/asset_position.py +106 -0
  167. wbportfolio/import_export/parsers/ubs/api/fees.py +31 -0
  168. wbportfolio/import_export/parsers/ubs/api/instrument_price.py +20 -0
  169. wbportfolio/import_export/parsers/ubs/api/utils.py +0 -0
  170. wbportfolio/import_export/parsers/ubs/customer_trade.py +60 -0
  171. wbportfolio/import_export/parsers/ubs/equity.py +97 -0
  172. wbportfolio/import_export/parsers/ubs/historical_customer_trade.py +67 -0
  173. wbportfolio/import_export/parsers/ubs/valuation.py +52 -0
  174. wbportfolio/import_export/parsers/vontobel/__init__.py +0 -0
  175. wbportfolio/import_export/parsers/vontobel/asset_position.py +97 -0
  176. wbportfolio/import_export/parsers/vontobel/customer_trade.py +54 -0
  177. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +40 -0
  178. wbportfolio/import_export/parsers/vontobel/instrument.py +34 -0
  179. wbportfolio/import_export/parsers/vontobel/management_fees.py +86 -0
  180. wbportfolio/import_export/parsers/vontobel/performance_fees.py +35 -0
  181. wbportfolio/import_export/parsers/vontobel/trade.py +38 -0
  182. wbportfolio/import_export/parsers/vontobel/utils.py +17 -0
  183. wbportfolio/import_export/parsers/vontobel/valuation.py +29 -0
  184. wbportfolio/import_export/resources/__init__.py +0 -0
  185. wbportfolio/import_export/resources/assets.py +68 -0
  186. wbportfolio/import_export/resources/trades.py +41 -0
  187. wbportfolio/import_export/utils.py +42 -0
  188. wbportfolio/metric/__init__.py +0 -0
  189. wbportfolio/metric/backends/__init__.py +2 -0
  190. wbportfolio/metric/backends/base.py +86 -0
  191. wbportfolio/metric/backends/constants.py +222 -0
  192. wbportfolio/metric/backends/portfolio_base.py +255 -0
  193. wbportfolio/metric/backends/portfolio_esg.py +66 -0
  194. wbportfolio/metric/tests/__init__.py +0 -0
  195. wbportfolio/metric/tests/conftest.py +4 -0
  196. wbportfolio/metric/tests/test_portfolio_base.py +135 -0
  197. wbportfolio/metric/tests/test_portfolio_esg.py +69 -0
  198. wbportfolio/migrations/0001_initial_squashed.py +13848 -0
  199. wbportfolio/migrations/0002_product_default_sub_account_squashed_0039_alter_assetallocation_company_and_more.py +3836 -0
  200. wbportfolio/migrations/0040_instrument_financial_instrument.py +26 -0
  201. wbportfolio/migrations/0041_remove_listresearch_research_ptr_and_more.py +129 -0
  202. wbportfolio/migrations/0042_instrumentlist_instrumentlistthroughmodel_and_more.py +71 -0
  203. wbportfolio/migrations/0043_alter_instrumentlistthroughmodel_options_and_more.py +238 -0
  204. wbportfolio/migrations/0044_alter_instrumentlist_identifier.py +35 -0
  205. wbportfolio/migrations/0045_alter_instrument_financial_instrument.py +26 -0
  206. wbportfolio/migrations/0046_add_product_default_account.py +166 -0
  207. wbportfolio/migrations/0047_remove_product_default_sub_account.py +14 -0
  208. wbportfolio/migrations/0048_alter_trade_status.py +29 -0
  209. wbportfolio/migrations/0049_trade_claimed_shares.py +25 -0
  210. wbportfolio/migrations/0050_fees_fee_date_fees_wbportfolio_transac_1f7a29_idx.py +44 -0
  211. wbportfolio/migrations/0051_delete_macroreview.py +11 -0
  212. wbportfolio/migrations/0052_remove_cash_instrument_ptr_and_more.py +888 -0
  213. wbportfolio/migrations/0053_remove_product_group.py +132 -0
  214. wbportfolio/migrations/0054_portfolioinstrumentpreferredclassificationthroughmodel_and_more.py +270 -0
  215. wbportfolio/migrations/0055_remove_product__custom_management_rebates_and_more.py +139 -0
  216. wbportfolio/migrations/0056_remove_companyportfoliodata_assets_under_management_currency_and_more.py +56 -0
  217. wbportfolio/migrations/0057_alter_portfolio_preferred_instrument_classifications_and_more.py +36 -0
  218. wbportfolio/migrations/0058_pmsinstrument.py +23 -0
  219. wbportfolio/migrations/0059_fees_unique_fees.py +51 -0
  220. wbportfolio/migrations/0060_alter_portfolioportfoliothroughmodel_type.py +21 -0
  221. wbportfolio/migrations/0061_portfolio_bank_accounts_product_bank_account_and_more.py +175 -0
  222. wbportfolio/migrations/0062_alter_dailyportfoliocashflow_options.py +20 -0
  223. wbportfolio/migrations/0063_accountreconciliation_accountreconciliationline_and_more.py +133 -0
  224. wbportfolio/migrations/0064_alter_portfolio_managers_portfolio_is_tracked_and_more.py +40 -0
  225. wbportfolio/migrations/0065_alter_portfolio_managers_claim_as_shares_and_more.py +73 -0
  226. wbportfolio/migrations/0066_assetposition_initial_shares_at_custodian_and_more.py +108 -0
  227. wbportfolio/migrations/0067_assetposition_unique_asset_position.py +77 -0
  228. wbportfolio/migrations/0068_trade_internal_trade_trade_marked_as_internal_and_more.py +59 -0
  229. wbportfolio/migrations/0069_remove_portfolio_is_invested_and_more.py +56 -0
  230. wbportfolio/migrations/0070_remove_assetposition_unique_asset_position_and_more.py +82 -0
  231. wbportfolio/migrations/0071_alter_trade_options_alter_trade_order.py +22 -0
  232. wbportfolio/migrations/__init__.py +0 -0
  233. wbportfolio/models/__init__.py +26 -0
  234. wbportfolio/models/adjustments.py +246 -0
  235. wbportfolio/models/asset.py +869 -0
  236. wbportfolio/models/custodians.py +101 -0
  237. wbportfolio/models/indexes.py +33 -0
  238. wbportfolio/models/mixins/__init__.py +0 -0
  239. wbportfolio/models/mixins/instruments.py +127 -0
  240. wbportfolio/models/mixins/liquidity_stress_test.py +1307 -0
  241. wbportfolio/models/portfolio.py +1039 -0
  242. wbportfolio/models/portfolio_cash_flow.py +167 -0
  243. wbportfolio/models/portfolio_cash_targets.py +46 -0
  244. wbportfolio/models/portfolio_relationship.py +135 -0
  245. wbportfolio/models/portfolio_swing_pricings.py +51 -0
  246. wbportfolio/models/product_groups.py +230 -0
  247. wbportfolio/models/products.py +569 -0
  248. wbportfolio/models/reconciliations/__init__.py +2 -0
  249. wbportfolio/models/reconciliations/account_reconciliation_lines.py +192 -0
  250. wbportfolio/models/reconciliations/account_reconciliations.py +102 -0
  251. wbportfolio/models/reconciliations/reconciliations.py +25 -0
  252. wbportfolio/models/registers.py +132 -0
  253. wbportfolio/models/roles.py +208 -0
  254. wbportfolio/models/synchronization/__init__.py +3 -0
  255. wbportfolio/models/synchronization/portfolio_synchronization.py +292 -0
  256. wbportfolio/models/synchronization/price_computation.py +200 -0
  257. wbportfolio/models/synchronization/synchronization.py +188 -0
  258. wbportfolio/models/transactions/__init__.py +7 -0
  259. wbportfolio/models/transactions/claim.py +634 -0
  260. wbportfolio/models/transactions/dividends.py +31 -0
  261. wbportfolio/models/transactions/expiry.py +7 -0
  262. wbportfolio/models/transactions/fees.py +153 -0
  263. wbportfolio/models/transactions/trade_proposals.py +502 -0
  264. wbportfolio/models/transactions/trades.py +704 -0
  265. wbportfolio/models/transactions/transactions.py +211 -0
  266. wbportfolio/models/utils.py +12 -0
  267. wbportfolio/permissions.py +13 -0
  268. wbportfolio/pms/__init__.py +0 -0
  269. wbportfolio/pms/statistics/__init__.py +0 -0
  270. wbportfolio/pms/trading/__init__.py +1 -0
  271. wbportfolio/pms/trading/handler.py +164 -0
  272. wbportfolio/pms/typing.py +194 -0
  273. wbportfolio/preferences.py +6 -0
  274. wbportfolio/reports/__init__.py +0 -0
  275. wbportfolio/reports/monthly_position_report.py +74 -0
  276. wbportfolio/risk_management/__init__.py +0 -0
  277. wbportfolio/risk_management/backends/__init__.py +11 -0
  278. wbportfolio/risk_management/backends/accounts.py +166 -0
  279. wbportfolio/risk_management/backends/controversy_portfolio.py +63 -0
  280. wbportfolio/risk_management/backends/exposure_portfolio.py +203 -0
  281. wbportfolio/risk_management/backends/instrument_list_portfolio.py +89 -0
  282. wbportfolio/risk_management/backends/liquidity_risk.py +86 -0
  283. wbportfolio/risk_management/backends/liquidity_stress_instrument.py +86 -0
  284. wbportfolio/risk_management/backends/mixins.py +220 -0
  285. wbportfolio/risk_management/backends/product_integrity.py +111 -0
  286. wbportfolio/risk_management/backends/stop_loss_instrument.py +24 -0
  287. wbportfolio/risk_management/backends/stop_loss_portfolio.py +36 -0
  288. wbportfolio/risk_management/backends/ucits_portfolio.py +63 -0
  289. wbportfolio/risk_management/tests/__init__.py +0 -0
  290. wbportfolio/risk_management/tests/conftest.py +15 -0
  291. wbportfolio/risk_management/tests/test_accounts.py +98 -0
  292. wbportfolio/risk_management/tests/test_controversy_portfolio.py +33 -0
  293. wbportfolio/risk_management/tests/test_exposure_portfolio.py +94 -0
  294. wbportfolio/risk_management/tests/test_instrument_list_portfolio.py +60 -0
  295. wbportfolio/risk_management/tests/test_liquidity_risk.py +47 -0
  296. wbportfolio/risk_management/tests/test_product_integrity.py +55 -0
  297. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +110 -0
  298. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +119 -0
  299. wbportfolio/risk_management/tests/test_ucits_portfolio.py +39 -0
  300. wbportfolio/serializers/__init__.py +42 -0
  301. wbportfolio/serializers/adjustments.py +24 -0
  302. wbportfolio/serializers/assets.py +166 -0
  303. wbportfolio/serializers/custodians.py +26 -0
  304. wbportfolio/serializers/portfolio_cash_flow.py +48 -0
  305. wbportfolio/serializers/portfolio_cash_targets.py +20 -0
  306. wbportfolio/serializers/portfolio_relationship.py +53 -0
  307. wbportfolio/serializers/portfolio_swing_pricing.py +20 -0
  308. wbportfolio/serializers/portfolios.py +143 -0
  309. wbportfolio/serializers/positions.py +76 -0
  310. wbportfolio/serializers/product_group.py +88 -0
  311. wbportfolio/serializers/products.py +331 -0
  312. wbportfolio/serializers/reconciliations.py +171 -0
  313. wbportfolio/serializers/registers.py +72 -0
  314. wbportfolio/serializers/roles.py +60 -0
  315. wbportfolio/serializers/signals.py +157 -0
  316. wbportfolio/serializers/synchronization.py +18 -0
  317. wbportfolio/serializers/transactions/__init__.py +24 -0
  318. wbportfolio/serializers/transactions/claim.py +310 -0
  319. wbportfolio/serializers/transactions/dividends.py +18 -0
  320. wbportfolio/serializers/transactions/expiry.py +18 -0
  321. wbportfolio/serializers/transactions/fees.py +32 -0
  322. wbportfolio/serializers/transactions/trades.py +315 -0
  323. wbportfolio/serializers/transactions/transactions.py +84 -0
  324. wbportfolio/tasks.py +125 -0
  325. wbportfolio/tests/__init__.py +0 -0
  326. wbportfolio/tests/conftest.py +164 -0
  327. wbportfolio/tests/models/__init__.py +0 -0
  328. wbportfolio/tests/models/test_account_reconciliation.py +191 -0
  329. wbportfolio/tests/models/test_assets.py +193 -0
  330. wbportfolio/tests/models/test_custodians.py +12 -0
  331. wbportfolio/tests/models/test_customer_trades.py +113 -0
  332. wbportfolio/tests/models/test_dividends.py +7 -0
  333. wbportfolio/tests/models/test_imports.py +192 -0
  334. wbportfolio/tests/models/test_instrument_mixins.py +48 -0
  335. wbportfolio/tests/models/test_merge.py +133 -0
  336. wbportfolio/tests/models/test_portfolio_cash_flow.py +112 -0
  337. wbportfolio/tests/models/test_portfolio_cash_targets.py +27 -0
  338. wbportfolio/tests/models/test_portfolio_swing_pricings.py +42 -0
  339. wbportfolio/tests/models/test_portfolios.py +676 -0
  340. wbportfolio/tests/models/test_product_groups.py +80 -0
  341. wbportfolio/tests/models/test_products.py +187 -0
  342. wbportfolio/tests/models/test_roles.py +82 -0
  343. wbportfolio/tests/models/test_splits.py +233 -0
  344. wbportfolio/tests/models/test_synchronization.py +617 -0
  345. wbportfolio/tests/models/transactions/__init__.py +0 -0
  346. wbportfolio/tests/models/transactions/test_claim.py +129 -0
  347. wbportfolio/tests/models/transactions/test_fees.py +65 -0
  348. wbportfolio/tests/models/transactions/test_trades.py +204 -0
  349. wbportfolio/tests/models/utils.py +13 -0
  350. wbportfolio/tests/serializers/__init__.py +0 -0
  351. wbportfolio/tests/serializers/test_claims.py +21 -0
  352. wbportfolio/tests/signals.py +151 -0
  353. wbportfolio/tests/tests.py +31 -0
  354. wbportfolio/tests/viewsets/__init__.py +0 -0
  355. wbportfolio/tests/viewsets/test_assets.py +67 -0
  356. wbportfolio/tests/viewsets/test_performances.py +72 -0
  357. wbportfolio/tests/viewsets/test_products.py +92 -0
  358. wbportfolio/tests/viewsets/transactions/__init__.py +0 -0
  359. wbportfolio/tests/viewsets/transactions/test_claims.py +146 -0
  360. wbportfolio/urls.py +247 -0
  361. wbportfolio/utils.py +30 -0
  362. wbportfolio/viewsets/__init__.py +57 -0
  363. wbportfolio/viewsets/adjustments.py +46 -0
  364. wbportfolio/viewsets/assets.py +562 -0
  365. wbportfolio/viewsets/assets_and_net_new_money_progression.py +117 -0
  366. wbportfolio/viewsets/charts/__init__.py +1 -0
  367. wbportfolio/viewsets/charts/assets.py +247 -0
  368. wbportfolio/viewsets/configs/__init__.py +6 -0
  369. wbportfolio/viewsets/configs/buttons/__init__.py +23 -0
  370. wbportfolio/viewsets/configs/buttons/adjustments.py +13 -0
  371. wbportfolio/viewsets/configs/buttons/assets.py +145 -0
  372. wbportfolio/viewsets/configs/buttons/claims.py +83 -0
  373. wbportfolio/viewsets/configs/buttons/custodians.py +76 -0
  374. wbportfolio/viewsets/configs/buttons/fees.py +14 -0
  375. wbportfolio/viewsets/configs/buttons/mixins.py +88 -0
  376. wbportfolio/viewsets/configs/buttons/portfolios.py +115 -0
  377. wbportfolio/viewsets/configs/buttons/products.py +41 -0
  378. wbportfolio/viewsets/configs/buttons/reconciliations.py +65 -0
  379. wbportfolio/viewsets/configs/buttons/registers.py +11 -0
  380. wbportfolio/viewsets/configs/buttons/signals.py +68 -0
  381. wbportfolio/viewsets/configs/buttons/trade_proposals.py +25 -0
  382. wbportfolio/viewsets/configs/buttons/trades.py +144 -0
  383. wbportfolio/viewsets/configs/display/__init__.py +61 -0
  384. wbportfolio/viewsets/configs/display/adjustments.py +81 -0
  385. wbportfolio/viewsets/configs/display/assets.py +265 -0
  386. wbportfolio/viewsets/configs/display/claim.py +299 -0
  387. wbportfolio/viewsets/configs/display/custodians.py +24 -0
  388. wbportfolio/viewsets/configs/display/esg.py +88 -0
  389. wbportfolio/viewsets/configs/display/fees.py +133 -0
  390. wbportfolio/viewsets/configs/display/portfolio_cash_flow.py +103 -0
  391. wbportfolio/viewsets/configs/display/portfolio_relationship.py +38 -0
  392. wbportfolio/viewsets/configs/display/portfolios.py +125 -0
  393. wbportfolio/viewsets/configs/display/positions.py +75 -0
  394. wbportfolio/viewsets/configs/display/product_groups.py +54 -0
  395. wbportfolio/viewsets/configs/display/product_performance.py +241 -0
  396. wbportfolio/viewsets/configs/display/products.py +249 -0
  397. wbportfolio/viewsets/configs/display/reconciliations.py +151 -0
  398. wbportfolio/viewsets/configs/display/registers.py +71 -0
  399. wbportfolio/viewsets/configs/display/roles.py +49 -0
  400. wbportfolio/viewsets/configs/display/trade_proposals.py +97 -0
  401. wbportfolio/viewsets/configs/display/trades.py +359 -0
  402. wbportfolio/viewsets/configs/display/transactions.py +55 -0
  403. wbportfolio/viewsets/configs/endpoints/__init__.py +75 -0
  404. wbportfolio/viewsets/configs/endpoints/adjustments.py +17 -0
  405. wbportfolio/viewsets/configs/endpoints/assets.py +115 -0
  406. wbportfolio/viewsets/configs/endpoints/claim.py +106 -0
  407. wbportfolio/viewsets/configs/endpoints/custodians.py +6 -0
  408. wbportfolio/viewsets/configs/endpoints/esg.py +14 -0
  409. wbportfolio/viewsets/configs/endpoints/fees.py +26 -0
  410. wbportfolio/viewsets/configs/endpoints/portfolio_relationship.py +23 -0
  411. wbportfolio/viewsets/configs/endpoints/portfolios.py +43 -0
  412. wbportfolio/viewsets/configs/endpoints/positions.py +18 -0
  413. wbportfolio/viewsets/configs/endpoints/product_groups.py +11 -0
  414. wbportfolio/viewsets/configs/endpoints/product_performance.py +29 -0
  415. wbportfolio/viewsets/configs/endpoints/products.py +37 -0
  416. wbportfolio/viewsets/configs/endpoints/reconciliations.py +31 -0
  417. wbportfolio/viewsets/configs/endpoints/roles.py +9 -0
  418. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +17 -0
  419. wbportfolio/viewsets/configs/endpoints/trades.py +82 -0
  420. wbportfolio/viewsets/configs/endpoints/transactions.py +17 -0
  421. wbportfolio/viewsets/configs/menu/__init__.py +30 -0
  422. wbportfolio/viewsets/configs/menu/adjustments.py +8 -0
  423. wbportfolio/viewsets/configs/menu/assets.py +8 -0
  424. wbportfolio/viewsets/configs/menu/claim.py +41 -0
  425. wbportfolio/viewsets/configs/menu/custodians.py +11 -0
  426. wbportfolio/viewsets/configs/menu/fees.py +13 -0
  427. wbportfolio/viewsets/configs/menu/instrument_prices.py +10 -0
  428. wbportfolio/viewsets/configs/menu/portfolio_cash_flow.py +8 -0
  429. wbportfolio/viewsets/configs/menu/portfolios.py +15 -0
  430. wbportfolio/viewsets/configs/menu/positions.py +14 -0
  431. wbportfolio/viewsets/configs/menu/product_groups.py +10 -0
  432. wbportfolio/viewsets/configs/menu/product_performance.py +25 -0
  433. wbportfolio/viewsets/configs/menu/products.py +15 -0
  434. wbportfolio/viewsets/configs/menu/reconciliations.py +7 -0
  435. wbportfolio/viewsets/configs/menu/registers.py +10 -0
  436. wbportfolio/viewsets/configs/menu/roles.py +16 -0
  437. wbportfolio/viewsets/configs/menu/trades.py +18 -0
  438. wbportfolio/viewsets/configs/menu/transactions.py +8 -0
  439. wbportfolio/viewsets/configs/previews/__init__.py +1 -0
  440. wbportfolio/viewsets/configs/previews/portfolios.py +21 -0
  441. wbportfolio/viewsets/configs/titles/__init__.py +65 -0
  442. wbportfolio/viewsets/configs/titles/adjustments.py +19 -0
  443. wbportfolio/viewsets/configs/titles/assets.py +57 -0
  444. wbportfolio/viewsets/configs/titles/assets_and_net_new_money_progression.py +6 -0
  445. wbportfolio/viewsets/configs/titles/claim.py +81 -0
  446. wbportfolio/viewsets/configs/titles/custodians.py +12 -0
  447. wbportfolio/viewsets/configs/titles/esg.py +10 -0
  448. wbportfolio/viewsets/configs/titles/fees.py +25 -0
  449. wbportfolio/viewsets/configs/titles/instrument_prices.py +20 -0
  450. wbportfolio/viewsets/configs/titles/portfolios.py +32 -0
  451. wbportfolio/viewsets/configs/titles/positions.py +11 -0
  452. wbportfolio/viewsets/configs/titles/product_groups.py +12 -0
  453. wbportfolio/viewsets/configs/titles/product_performance.py +16 -0
  454. wbportfolio/viewsets/configs/titles/products.py +6 -0
  455. wbportfolio/viewsets/configs/titles/registers.py +12 -0
  456. wbportfolio/viewsets/configs/titles/roles.py +23 -0
  457. wbportfolio/viewsets/configs/titles/trades.py +51 -0
  458. wbportfolio/viewsets/configs/titles/transactions.py +8 -0
  459. wbportfolio/viewsets/custodians.py +66 -0
  460. wbportfolio/viewsets/esg.py +165 -0
  461. wbportfolio/viewsets/mixins.py +48 -0
  462. wbportfolio/viewsets/portfolio_cash_flow.py +31 -0
  463. wbportfolio/viewsets/portfolio_cash_targets.py +8 -0
  464. wbportfolio/viewsets/portfolio_relationship.py +46 -0
  465. wbportfolio/viewsets/portfolio_swing_pricing.py +8 -0
  466. wbportfolio/viewsets/portfolios.py +154 -0
  467. wbportfolio/viewsets/positions.py +292 -0
  468. wbportfolio/viewsets/product_groups.py +84 -0
  469. wbportfolio/viewsets/product_performance.py +646 -0
  470. wbportfolio/viewsets/products.py +529 -0
  471. wbportfolio/viewsets/reconciliations.py +160 -0
  472. wbportfolio/viewsets/registers.py +75 -0
  473. wbportfolio/viewsets/roles.py +44 -0
  474. wbportfolio/viewsets/signals.py +42 -0
  475. wbportfolio/viewsets/synchronization.py +25 -0
  476. wbportfolio/viewsets/transactions/__init__.py +40 -0
  477. wbportfolio/viewsets/transactions/claim.py +933 -0
  478. wbportfolio/viewsets/transactions/fees.py +190 -0
  479. wbportfolio/viewsets/transactions/mixins.py +19 -0
  480. wbportfolio/viewsets/transactions/trade_proposals.py +93 -0
  481. wbportfolio/viewsets/transactions/trades.py +395 -0
  482. wbportfolio/viewsets/transactions/transactions.py +123 -0
  483. wbportfolio-2.2.1.dist-info/METADATA +21 -0
  484. wbportfolio-2.2.1.dist-info/RECORD +486 -0
  485. wbportfolio-2.2.1.dist-info/WHEEL +5 -0
  486. wbportfolio-2.2.1.dist-info/licenses/LICENSE +4 -0
@@ -0,0 +1,1039 @@
1
+ from contextlib import suppress
2
+ from datetime import date, datetime, timedelta
3
+ from decimal import Decimal
4
+ from math import isclose
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from celery import shared_task
10
+ from django.contrib.postgres.fields import DateRangeField
11
+ from django.db import models
12
+ from django.db.models import (
13
+ BooleanField,
14
+ Case,
15
+ Exists,
16
+ F,
17
+ OuterRef,
18
+ Q,
19
+ QuerySet,
20
+ Sum,
21
+ Value,
22
+ When,
23
+ )
24
+ from django.db.models.signals import post_save
25
+ from django.dispatch import receiver
26
+ from psycopg.types.range import DateRange
27
+ from wbcore.contrib.currency.models import CurrencyFXRates
28
+ from wbcore.models import WBModel
29
+ from wbcore.utils.models import ActiveObjectManager, DeleteToDisableMixin
30
+ from wbfdm.contrib.metric.dispatch import compute_metrics
31
+ from wbfdm.models import Instrument
32
+ from wbfdm.models.instruments.instrument_prices import InstrumentPrice
33
+ from wbportfolio.models.asset import AssetPosition
34
+ from wbportfolio.models.indexes import Index
35
+ from wbportfolio.models.portfolio_relationship import (
36
+ InstrumentPortfolioThroughModel,
37
+ PortfolioInstrumentPreferredClassificationThroughModel,
38
+ )
39
+ from wbportfolio.models.products import Product
40
+ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
41
+
42
+ from .utils import get_casted_portfolio_instrument
43
+
44
+
45
+ class DefaultPortfolioQueryset(QuerySet):
46
+ def filter_invested_at_date(self, val_date: date) -> QuerySet:
47
+ """
48
+ Filter the queryset to get only portfolio invested at the given date
49
+ """
50
+ return self.filter(invested_timespan__startswith__lte=val_date, invested_timespan__endswith__gt=val_date)
51
+
52
+
53
+ class DefaultPortfolioManager(ActiveObjectManager):
54
+ def get_queryset(self):
55
+ return DefaultPortfolioQueryset(self.model).filter(is_active=True)
56
+
57
+ def filter_invested_at_date(self, val_date: date):
58
+ return self.get_queryset().filter_invested_at_date(val_date)
59
+
60
+
61
+ class ActiveTrackedPortfolioManager(DefaultPortfolioManager):
62
+ def get_queryset(self):
63
+ return (
64
+ super()
65
+ .get_queryset()
66
+ .annotate(asset_exists=Exists(AssetPosition.objects.filter(portfolio=OuterRef("pk"))))
67
+ .filter(asset_exists=True, is_tracked=True)
68
+ )
69
+
70
+
71
+ class PortfolioPortfolioThroughModel(models.Model):
72
+ class Type(models.TextChoices):
73
+ PRIMARY = "PRIMARY", "Primary"
74
+ MODEL = "MODEL", "Model"
75
+ BENCHMARK = "BENCHMARK", "Benchmark"
76
+ INDEX = "INDEX", "Index"
77
+ CUSTODIAN = "CUSTODIAN", "Custodian"
78
+
79
+ portfolio = models.ForeignKey("wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependency_through")
80
+ dependency_portfolio = models.ForeignKey(
81
+ "wbportfolio.Portfolio", on_delete=models.CASCADE, related_name="dependent_through"
82
+ )
83
+ type = models.CharField(choices=Type.choices, default=Type.PRIMARY, verbose_name="Type")
84
+
85
+ class Meta:
86
+ constraints = [
87
+ models.UniqueConstraint(fields=["portfolio", "type"], name="unique_primary", condition=Q(type="PRIMARY")),
88
+ models.UniqueConstraint(fields=["portfolio", "type"], name="unique_model", condition=Q(type="MODEL")),
89
+ ]
90
+
91
+
92
+ class Portfolio(DeleteToDisableMixin, WBModel):
93
+ assets: models.QuerySet[AssetPosition]
94
+
95
+ name = models.CharField(
96
+ max_length=255,
97
+ verbose_name="Name",
98
+ default="",
99
+ help_text="The Name of the Portfolio",
100
+ )
101
+
102
+ currency = models.ForeignKey(
103
+ to="currency.Currency",
104
+ related_name="portfolios",
105
+ on_delete=models.PROTECT,
106
+ verbose_name="Currency",
107
+ help_text="The currency of the portfolio.",
108
+ )
109
+ hedged_currency = models.ForeignKey(
110
+ to="currency.Currency",
111
+ related_name="hedged_portfolios",
112
+ on_delete=models.PROTECT,
113
+ blank=True,
114
+ null=True,
115
+ verbose_name="Hedged Currency",
116
+ help_text="The hedged currency of the portfolio.",
117
+ )
118
+ depends_on = models.ManyToManyField(
119
+ "wbportfolio.Portfolio",
120
+ symmetrical=False,
121
+ related_name="dependent_portfolios",
122
+ through="wbportfolio.PortfolioPortfolioThroughModel",
123
+ through_fields=("portfolio", "dependency_portfolio"),
124
+ blank=True,
125
+ verbose_name="The portfolios this portfolio depends on",
126
+ )
127
+
128
+ portfolio_synchronization = models.ForeignKey(
129
+ "wbportfolio.PortfolioSynchronization",
130
+ null=True,
131
+ blank=True,
132
+ on_delete=models.SET_NULL,
133
+ related_name="portfolios",
134
+ verbose_name="Portfolio Synchronization Method",
135
+ )
136
+ preferred_instrument_classifications = models.ManyToManyField(
137
+ "wbfdm.Instrument",
138
+ limit_choices_to=(models.Q(instrument_type__is_classifiable=True) & models.Q(level=0)),
139
+ related_name="preferred_portfolio_classifications",
140
+ through="wbportfolio.PortfolioInstrumentPreferredClassificationThroughModel",
141
+ through_fields=("portfolio", "instrument"),
142
+ blank=True,
143
+ verbose_name="The Preferred classification per instrument",
144
+ )
145
+ instruments = models.ManyToManyField(
146
+ "wbfdm.Instrument",
147
+ through=InstrumentPortfolioThroughModel,
148
+ related_name="portfolios",
149
+ blank=True,
150
+ verbose_name="Instruments",
151
+ help_text="Instruments linked to this instrument",
152
+ )
153
+ invested_timespan = DateRangeField(
154
+ null=True, blank=True, help_text="Define when this portfolio is considered invested"
155
+ )
156
+
157
+ is_manageable = models.BooleanField(
158
+ default=False,
159
+ help_text="True if the portfolio can be manually modified (e.g. Trade proposal be submitted or total weight recomputed)",
160
+ )
161
+ is_tracked = models.BooleanField(
162
+ default=True,
163
+ help_text="True if the internal updating mechanism (e.g., Propagation, Synchronization etc...) needs to apply to this portfolio",
164
+ )
165
+ only_weighting = models.BooleanField(
166
+ default=False,
167
+ help_text="Indicates that this portfolio is only utilizing weights and disregards shares, e.g. a model portfolio",
168
+ )
169
+
170
+ last_synchronization = models.DateTimeField(blank=True, null=True, verbose_name="Last Synchronization Date")
171
+ bank_accounts = models.ManyToManyField(
172
+ to="directory.BankingContact",
173
+ related_name="wbportfolio_portfolios",
174
+ through="wbportfolio.PortfolioBankAccountThroughModel",
175
+ blank=True,
176
+ )
177
+ objects = DefaultPortfolioManager()
178
+ tracked_objects = ActiveTrackedPortfolioManager()
179
+
180
+ @property
181
+ def primary_portfolio(self):
182
+ with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
183
+ return PortfolioPortfolioThroughModel.objects.get(
184
+ portfolio=self, type=PortfolioPortfolioThroughModel.Type.PRIMARY
185
+ ).dependency_portfolio
186
+
187
+ @property
188
+ def model_portfolio(self):
189
+ with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
190
+ return PortfolioPortfolioThroughModel.objects.get(
191
+ portfolio=self, type=PortfolioPortfolioThroughModel.Type.MODEL
192
+ ).dependency_portfolio
193
+
194
+ @property
195
+ def benchmark_portfolio(self):
196
+ with suppress(PortfolioPortfolioThroughModel.DoesNotExist):
197
+ return PortfolioPortfolioThroughModel.objects.get(
198
+ portfolio=self, type=PortfolioPortfolioThroughModel.Type.BENCHMARK
199
+ ).dependency_portfolio
200
+
201
+ @property
202
+ def imported_assets(self):
203
+ return self.assets.filter(is_estimated=False)
204
+
205
+ def delete(self, **kwargs):
206
+ super().delete(**kwargs)
207
+ # We check if for all linked instruments, this portfolio was the last active one (if yes, we disable the instrument)
208
+ if self.id:
209
+ for instrument in self.instruments.iterator():
210
+ if not instrument.portfolios.filter(is_active=True).exists():
211
+ instrument.delisted_date = date.today() - timedelta(days=1)
212
+ instrument.save()
213
+
214
+ def _build_dto(self, val_date: date, **extra_filter_kwargs) -> PortfolioDTO:
215
+ "returns the dto representation of this portfolio at the specified date"
216
+ return PortfolioDTO(
217
+ tuple([pos._build_dto() for pos in self.assets.filter(date=val_date, **extra_filter_kwargs)])
218
+ )
219
+
220
+ def is_invested_at_date(self, val_date: date) -> bool:
221
+ return (
222
+ self.invested_timespan
223
+ and self.invested_timespan.upper > val_date
224
+ and self.invested_timespan.lower <= val_date
225
+ )
226
+
227
+ def __str__(self):
228
+ return f"{self.id:06} ({self.name})"
229
+
230
+ class Meta:
231
+ verbose_name = "Portfolio"
232
+ verbose_name_plural = "Portfolios"
233
+
234
+ notification_types = [
235
+ (
236
+ "wbportfolio.portfolio.check_custodian_portfolio",
237
+ "Check Custodian Portfolio",
238
+ "Sends a notification when a portfolio does not match with its custodian portfolio",
239
+ True,
240
+ True,
241
+ True,
242
+ ),
243
+ ]
244
+
245
+ @classmethod
246
+ def create_model_portfolio(cls, name, currency, portfolio_synchronization=None, index_parameters=dict()):
247
+ portfolio = cls.objects.create(
248
+ is_manageable=True,
249
+ name=name,
250
+ currency=currency,
251
+ portfolio_synchronization=portfolio_synchronization,
252
+ )
253
+ if index_parameters:
254
+ index = Index.objects.create(name=name, currency=currency, **index_parameters)
255
+ index.portfolios.all().delete()
256
+ InstrumentPortfolioThroughModel.objects.update_or_create(
257
+ instrument=index, defaults={"portfolio": portfolio}
258
+ )
259
+ return portfolio
260
+
261
+ def is_active_at_date(self, val_date: date) -> bool:
262
+ """
263
+ Return if the base instrument has a total aum greater than 0
264
+ :val_date: the date at which we need to evaluate if the portfolio is considered active
265
+ """
266
+ active_portfolio = self.is_active or self.deletion_datetime.date() > val_date
267
+ if self.instruments.exists():
268
+ return active_portfolio and any(
269
+ [instrument.is_active_at_date(val_date) for instrument in self.instruments.all()]
270
+ )
271
+ return active_portfolio
272
+
273
+ def get_aum(self, val_date: date) -> Decimal:
274
+ """
275
+ Return the total asset under management of the portfolio at the specified valuation date
276
+ Args:
277
+ val_date: The date at which aum needs to be computed
278
+ Returns:
279
+ The total AUM (0 if there is no position)
280
+ """
281
+ return self.assets.filter(date=val_date).aggregate(s=Sum("total_value_fx_portfolio"))["s"] or Decimal(0.0)
282
+
283
+ def get_total_value(self, val_date):
284
+ from wbportfolio.models.transactions.trades import Trade
285
+
286
+ trades = Trade.valid_customer_trade_objects.filter(portfolio=self, transaction_date__lte=val_date)
287
+
288
+ total_aum = Decimal(0)
289
+ for underlying_instrument_id, sum_shares in (
290
+ trades.values("underlying_instrument")
291
+ .annotate(
292
+ sum_shares=Sum("shares"),
293
+ )
294
+ .values_list("underlying_instrument", "sum_shares")
295
+ ):
296
+ with suppress(Instrument.DoesNotExist, InstrumentPrice.DoesNotExist):
297
+ instrument = Instrument.objects.get(id=underlying_instrument_id)
298
+ last_price = instrument.valuations.filter(date__lte=val_date).latest("date").net_value
299
+ fx_rate = instrument.currency.convert(val_date, self.currency)
300
+ total_aum += last_price * sum_shares * fx_rate
301
+ return total_aum
302
+
303
+ def _get_assets(self, with_estimated=True, with_cash=True):
304
+ qs = self.assets
305
+ if not with_estimated:
306
+ qs = qs.filter(is_estimated=False)
307
+ if not with_cash:
308
+ qs = qs.exclude(underlying_instrument__is_cash=True)
309
+ return qs
310
+
311
+ def get_earliest_asset_position_date(self, val_date=None, with_estimated=False):
312
+ qs = self._get_assets(with_estimated=with_estimated)
313
+ if val_date:
314
+ qs = qs.filter(date__gte=val_date)
315
+ if qs.exists():
316
+ return qs.earliest("date").date
317
+ return None
318
+
319
+ def get_latest_asset_position_date(self, val_date=None, with_estimated=False):
320
+ qs = self._get_assets(with_estimated=with_estimated)
321
+ if val_date:
322
+ qs = qs.filter(date__lte=val_date)
323
+
324
+ if qs.exists():
325
+ return qs.latest("date").date
326
+ return None
327
+
328
+ # Asset Position Utility Functions
329
+ def get_holding(self, val_date, exclude_cash=True, exclude_index=True):
330
+ qs = self._get_assets(with_cash=not exclude_cash).filter(date=val_date, weighting__gt=0)
331
+ if exclude_index:
332
+ qs = qs.exclude(underlying_security_instrument_type_key="index")
333
+ return (
334
+ qs.values("underlying_instrument__name")
335
+ .annotate(total_value_fx_portfolio=Sum("total_value_fx_portfolio"), weighting=Sum("weighting"))
336
+ .order_by("-total_value_fx_portfolio")
337
+ )
338
+
339
+ def _get_groupedby_df(
340
+ self,
341
+ group_by,
342
+ val_date: date,
343
+ exclude_cash: bool | None = False,
344
+ exclude_index: bool | None = False,
345
+ extra_filter_parameters: dict[str, Any] = None,
346
+ **groupby_kwargs,
347
+ ):
348
+ qs = self._get_assets(with_cash=not exclude_cash).filter(date=val_date)
349
+ if exclude_index:
350
+ # We exclude only index that are not considered as cash. Setting exclude_cash to true convers this case.
351
+ qs = qs.exclude(
352
+ Q(underlying_security_instrument_type_key="index") & Q(underlying_instrument__is_cash=False)
353
+ )
354
+ if extra_filter_parameters:
355
+ qs = qs.filter(**extra_filter_parameters)
356
+ qs = group_by(qs, **groupby_kwargs).annotate(sum_weighting=Sum(F("weighting"))).order_by("-sum_weighting")
357
+ df = pd.DataFrame(
358
+ qs.values_list("aggregated_title", "sum_weighting"), columns=["aggregated_title", "weighting"]
359
+ )
360
+ if not df.empty:
361
+ df.weighting = df.weighting.astype("float")
362
+ df.weighting = df.weighting / df.weighting.sum()
363
+ df = df.sort_values(by=["weighting"])
364
+ return df.where(pd.notnull(df), None)
365
+
366
+ def get_geographical_breakdown(self, val_date, **kwargs):
367
+ df = self._get_groupedby_df(
368
+ AssetPosition.country_group_by, val_date=val_date, exclude_cash=True, exclude_index=True, **kwargs
369
+ )
370
+ if not df.empty:
371
+ df = df[df["weighting"] != 0]
372
+ return df
373
+
374
+ def get_currency_exposure(self, val_date, **kwargs):
375
+ df = self._get_groupedby_df(AssetPosition.currency_group_by, val_date=val_date, **kwargs)
376
+ if not df.empty:
377
+ df = df[df["weighting"] != 0]
378
+ return df
379
+
380
+ def get_equity_market_cap_distribution(self, val_date, **kwargs):
381
+ df = self._get_groupedby_df(
382
+ AssetPosition.marketcap_group_by,
383
+ val_date=val_date,
384
+ exclude_cash=True,
385
+ exclude_index=True,
386
+ extra_filter_parameters={"underlying_security_instrument_type_key": "equity"},
387
+ **kwargs,
388
+ )
389
+ if not df.empty:
390
+ df = df[df["weighting"] != 0]
391
+ return df
392
+
393
+ def get_equity_liquidity(self, val_date, **kwargs):
394
+ df = self._get_groupedby_df(
395
+ AssetPosition.liquidity_group_by,
396
+ val_date=val_date,
397
+ exclude_cash=True,
398
+ exclude_index=True,
399
+ extra_filter_parameters={"underlying_security_instrument_type_key": "equity"},
400
+ **kwargs,
401
+ )
402
+ if not df.empty:
403
+ df = df[df["weighting"] != 0]
404
+ return df
405
+
406
+ def get_industry_exposure(self, val_date=None, **kwargs):
407
+ df = self._get_groupedby_df(
408
+ AssetPosition.group_by_primary, val_date=val_date, exclude_cash=True, exclude_index=True, **kwargs
409
+ )
410
+ if not df.empty:
411
+ df = df[df["weighting"] != 0]
412
+ return df
413
+
414
+ def get_asset_allocation(self, val_date=None, **kwargs):
415
+ df = self._get_groupedby_df(AssetPosition.cash_group_by, val_date=val_date, **kwargs)
416
+ if not df.empty:
417
+ df = df[df["weighting"] != 0]
418
+ return df
419
+
420
+ def get_adjusted_child_positions(self, val_date):
421
+ if (
422
+ child_positions := self.assets.exclude(underlying_instrument__is_cash=True).filter(date=val_date)
423
+ ).count() == 1:
424
+ if portfolio := child_positions.first().underlying_instrument.primary_portfolio:
425
+ child_positions = portfolio.assets.exclude(underlying_instrument__is_cash=True).filter(date=val_date)
426
+ for position in child_positions:
427
+ if child_portfolio := position.underlying_instrument.primary_portfolio:
428
+ index_positions = child_portfolio.assets.exclude(underlying_instrument__is_cash=True).filter(
429
+ date=val_date
430
+ )
431
+
432
+ for index_position in index_positions.all():
433
+ weighting = index_position.weighting * position.weighting
434
+ if weighting != 0:
435
+ yield {
436
+ "underlying_instrument_id": index_position.underlying_instrument.id,
437
+ "weighting": weighting,
438
+ }
439
+
440
+ def get_longshort_distribution(self, val_date):
441
+ df = pd.DataFrame(self.get_adjusted_child_positions(val_date))
442
+
443
+ if not df.empty:
444
+ df["is_cash"] = df.underlying_instrument_id.apply(lambda x: Instrument.objects.get(id=x).is_cash)
445
+ df = df[~df["is_cash"]]
446
+ df = (
447
+ df[["underlying_instrument_id", "weighting"]].groupby("underlying_instrument_id").sum().astype("float")
448
+ )
449
+ df.weighting = df.weighting / df.weighting.sum()
450
+ short_weight = df[df.weighting < 0].weighting.abs().sum()
451
+ long_weight = df[df.weighting > 0].weighting.sum()
452
+ total_weight = long_weight + short_weight
453
+ return pd.DataFrame(
454
+ [
455
+ {"title": "Long", "weighting": long_weight / total_weight},
456
+ {"title": "Short", "weighting": short_weight / total_weight},
457
+ ]
458
+ )
459
+ return df
460
+
461
+ def get_portfolio_contribution_df(self, start, end, with_cash=True, hedged_currency=None, only_equity=False):
462
+ qs = self._get_assets(with_cash=with_cash).filter(date__gte=start, date__lte=end)
463
+ if only_equity:
464
+ qs = qs.filter(underlying_security_instrument_type_key="equity")
465
+ return Portfolio.get_contribution_df(qs, hedged_currency=hedged_currency)
466
+
467
+ def check_related_portfolio_at_date(self, val_date: date, related_portfolio: "Portfolio"):
468
+ assets = AssetPosition.objects.filter(
469
+ date=val_date, underlying_instrument__is_cash=False, underlying_instrument__is_cash_equivalent=False
470
+ ).values("underlying_instrument__parent", "shares")
471
+ assets1 = assets.filter(portfolio=self)
472
+ assets2 = assets.filter(portfolio=related_portfolio)
473
+ return assets1.difference(assets2)
474
+
475
+ def change_at_date(
476
+ self,
477
+ val_date: date,
478
+ recompute_weighting: bool = False,
479
+ force_recompute_weighting: bool = False,
480
+ synchronize: bool = True,
481
+ **sync_kwargs,
482
+ ):
483
+ qs = (
484
+ self.assets.filter(date=val_date)
485
+ .filter(Q(total_value_fx_portfolio__isnull=False) | Q(weighting__isnull=False))
486
+ .distinct()
487
+ )
488
+
489
+ # We normalize weight across the portfolio for a given date
490
+ if (self.portfolio_synchronization or self.is_manageable or force_recompute_weighting) and qs.exists():
491
+ total_weighting = qs.aggregate(s=Sum("weighting"))["s"]
492
+ # We check if this actually necessary
493
+ # (i.e. if the weight is already summed to 100%, it is already normalized)
494
+ if not total_weighting or not isclose(total_weighting, Decimal(1.0), abs_tol=0.001) or recompute_weighting:
495
+ total_value = qs.aggregate(s=Sum("total_value_fx_portfolio"))["s"]
496
+ # TODO we change this because postgres doesn't support join statement in update (and total_value_fx_portfolio is a joined annoted field)
497
+ for asset in qs:
498
+ if total_value:
499
+ asset.weighting = asset._total_value_fx_portfolio / total_value
500
+ elif total_weighting:
501
+ asset.weighting = asset.weighting / total_weighting
502
+ asset.save()
503
+ if synchronize:
504
+ for dependent_portfolio in self.dependent_portfolios.exclude(id=self.id).distinct():
505
+ # Check if the dependent portfolio has a synchronization method and has assets at the specified date
506
+ if (synchronization := dependent_portfolio.portfolio_synchronization) and (
507
+ dependent_portfolio.assets.filter(date__gte=val_date).exists()
508
+ ):
509
+ # If this is true, we want to apply the synchronization at every synchronization period
510
+ # (scheduled crontab) from val_date to now.
511
+ if synchronization.propagate_history:
512
+ for _d in synchronization.dates_range(
513
+ val_date, dependent_portfolio.assets.latest("date").date, filter_daily=True
514
+ ):
515
+ synchronization.synchronize_as_task_si(
516
+ dependent_portfolio, _d, override_execution_datetime_validity=True
517
+ ).apply_async()
518
+ # Otherwise, we simply call a unique task for that date
519
+ else:
520
+ synchronization.synchronize_as_task_si(
521
+ dependent_portfolio, val_date, override_execution_datetime_validity=True
522
+ ).apply_async()
523
+
524
+ # We check if there is an instrument attached to the portfolio with calculated NAV and price computation method
525
+ for instrument in self.instruments.all():
526
+ if price_computation := getattr(
527
+ get_casted_portfolio_instrument(instrument), "price_computation", None
528
+ ):
529
+ inception_date = instrument.inception_date
530
+ if isinstance(inception_date, datetime):
531
+ inception_date = inception_date.date()
532
+
533
+ if isinstance(val_date, datetime):
534
+ val_date = val_date.date()
535
+
536
+ if inception_date is None or inception_date > val_date:
537
+ instrument.inception_date = val_date
538
+ instrument.save()
539
+ price_computation.compute(instrument, val_date, override_execution_datetime_validity=True)
540
+ compute_metrics(val_date, basket=self)
541
+
542
+ def propagate_or_update_assets(
543
+ self,
544
+ from_date: date,
545
+ to_date: date,
546
+ forward_price: bool | None = True,
547
+ base_assets: dict[str, str] | None = None,
548
+ delete_existing_assets: bool | None = False,
549
+ ):
550
+ # we don't propagate on already imported portfolio by default
551
+ is_target_portfolio_imported = self.assets.filter(date=to_date, is_estimated=False).exists()
552
+ if not base_assets:
553
+ base_assets = dict()
554
+
555
+ def _get_next_asset_valuation_date(current_asset_valuation_date):
556
+ return (current_asset_valuation_date + pd.offsets.BDay(np.busday_count(from_date, to_date))).date()
557
+
558
+ last_fx_date = CurrencyFXRates.objects.filter(date__lte=to_date).latest("date").date
559
+ fx_rates = CurrencyFXRates.objects.filter(date=last_fx_date)
560
+ assets = self.assets.filter(date=from_date)
561
+
562
+ from_is_active = self.is_active_at_date(from_date)
563
+ to_is_active = self.is_active_at_date(to_date)
564
+ # # We check is the current assets are already stored and if there is no already stored valid assets
565
+ # # With this, we ensure that we don't overwrite imported asset position with propagated ones.
566
+ # assets_positions_next_day_count = self.assets.filter(date=to_date).count()
567
+ if assets.exists() or base_assets:
568
+ # Remove already existing assets
569
+ if delete_existing_assets:
570
+ self.assets.filter(date=to_date).delete()
571
+ asset_list = list()
572
+ # If base_assets is provided,
573
+ # we assume that the portfolio composition is injected by this list of dictionary
574
+ if base_assets:
575
+ base_assets = (
576
+ base_assets
577
+ if isinstance(base_assets, dict)
578
+ else {asset_id: Decimal(1 / len(base_assets)) for asset_id in base_assets}
579
+ )
580
+
581
+ remaining_base_assets = base_assets.copy()
582
+ # Loop over existing assets and construct the propagation assets list
583
+ for asset in assets.all():
584
+ # if a composition is provided, we ensure that existing assets don't deviate from it
585
+ if (base_assets and asset.underlying_instrument.id in base_assets.keys()) or not base_assets:
586
+ next_asset_valuation_date = _get_next_asset_valuation_date(asset.asset_valuation_date)
587
+ with suppress(ValueError):
588
+ asset_list.append(
589
+ {
590
+ "initial_price": (
591
+ asset.initial_price
592
+ if asset._price is not None
593
+ else asset.underlying_instrument.get_price(from_date)
594
+ ),
595
+ "asset_valuation_date": next_asset_valuation_date,
596
+ "weighting": asset.weighting,
597
+ "next_initial_price": asset.underlying_instrument.get_price(next_asset_valuation_date),
598
+ "underlying_instrument": asset.underlying_instrument,
599
+ "exchange": asset.exchange,
600
+ "portfolio": asset.portfolio,
601
+ "portfolio_created": asset.portfolio_created,
602
+ "currency": asset.currency,
603
+ "initial_shares": asset.initial_shares,
604
+ }
605
+ )
606
+ remaining_base_assets.pop(asset.underlying_instrument.id, None)
607
+ # We ensure that the propagation assets list contains the proposed composition
608
+ for asset_id, weighting in remaining_base_assets.items():
609
+ instrument = Instrument.objects.get(id=asset_id)
610
+ with suppress(ValueError):
611
+ asset_list.append(
612
+ {
613
+ "underlying_instrument": instrument,
614
+ "initial_price": instrument.get_price(from_date),
615
+ "next_initial_price": instrument.get_price(to_date),
616
+ "asset_valuation_date": to_date,
617
+ "initial_shares": None,
618
+ "portfolio": self,
619
+ "currency": instrument.currency,
620
+ "weighting": weighting,
621
+ }
622
+ )
623
+
624
+ df = pd.DataFrame(asset_list)
625
+ if not df.empty:
626
+ df[["initial_price", "weighting", "next_initial_price"]] = df[
627
+ ["initial_price", "weighting", "next_initial_price"]
628
+ ].astype("float")
629
+ idxx = pd.isnull(df["initial_price"]) & ~pd.isnull(df["next_initial_price"])
630
+ df.loc[idxx, "initial_price"] = df.loc[idxx, "next_initial_price"]
631
+ if forward_price:
632
+ idx = pd.isnull(df["next_initial_price"])
633
+ df.loc[idx, "next_initial_price"] = df.loc[idx, "initial_price"]
634
+ df = df.dropna(axis=0, subset=["next_initial_price", "initial_price"])
635
+ # Normalize weight to 100%. Exclude portfolio were sum of weight equals 0 (e.g. short/long portfolio)
636
+ if df.weighting.sum() != 0:
637
+ df["weighting"] /= df.weighting.sum()
638
+ df.loc[:, "perf"] = df.loc[:, "next_initial_price"] / df.loc[:, "initial_price"]
639
+ df["contribution"] = df.perf * df.weighting
640
+ df.loc[:, "next_weighting"] = df.contribution
641
+
642
+ if df.contribution.sum() != 0:
643
+ df.loc[:, "next_weighting"] /= df.contribution.sum()
644
+
645
+ # Normalize next weighting
646
+ if df.next_weighting.sum() != 0:
647
+ df.next_weighting /= df.next_weighting.sum()
648
+ df = df.replace([np.inf, -np.inf, np.nan], None)
649
+ df.loc[(df["next_weighting"] < -1) | (df["next_weighting"] > 1), "next_weighting"] = df.loc[
650
+ (df["next_weighting"] < -1) | (df["next_weighting"] > 1), "weighting"
651
+ ] # if the next weighting is not including within -1 and 1 range, we default to the initial weighting
652
+ if not df.empty:
653
+ for row in df.to_dict("records"):
654
+ weighting = Decimal(row["next_weighting"]) if row["next_weighting"] else row["weighting"]
655
+ if from_is_active and not to_is_active:
656
+ weighting = Decimal(0.0)
657
+ try:
658
+ initial_currency_fx_rate = (
659
+ fx_rates.get(currency=self.currency).value
660
+ / fx_rates.get(currency=row["currency"]).value
661
+ )
662
+ except CurrencyFXRates.DoesNotExist:
663
+ initial_currency_fx_rate = Decimal(1)
664
+ defaults = {
665
+ "initial_currency_fx_rate": initial_currency_fx_rate,
666
+ "weighting": weighting,
667
+ "initial_price": Decimal(row["next_initial_price"]),
668
+ "initial_shares": row["initial_shares"],
669
+ "asset_valuation_date": row["asset_valuation_date"],
670
+ "is_estimated": True,
671
+ }
672
+ get_parameters = {
673
+ "underlying_instrument": row["underlying_instrument"],
674
+ "portfolio": self,
675
+ "currency": row["currency"],
676
+ "date": to_date,
677
+ }
678
+ if exchange := row.get("exchange", None):
679
+ get_parameters["exchange"] = exchange
680
+ if portfolio_created := row.get("portfolio_created", None):
681
+ get_parameters["portfolio_created"] = portfolio_created
682
+ # We check if an asset position already exists and if so, if it is estimated
683
+ # (otherwise we don't propagate it)
684
+ if _asset := AssetPosition.objects.filter(**get_parameters).first():
685
+ _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)
686
+ if not from_is_active and not to_is_active:
687
+ _asset.delete()
688
+ elif not is_target_portfolio_imported and _asset.is_estimated:
689
+ for k, v in defaults.items():
690
+ setattr(_asset, k, v)
691
+ _asset.save()
692
+ elif from_is_active and to_is_active and not is_target_portfolio_imported:
693
+ AssetPosition.objects.create(**get_parameters, **defaults)
694
+
695
+ def import_positions_at_date(self, portfolio: PortfolioDTO, val_date: date, post_processing: bool = False):
696
+ if not portfolio:
697
+ return
698
+ left_over_positions = self.assets.filter(date=val_date)
699
+
700
+ # We convert the positions into a dataframe in order to handle positions that are considered duplicates
701
+ # In that case, we sum up fields such as weighting and shares.
702
+ # Position are assumed serialized otherwise the groupby on dataframe can't handle django object
703
+ index_columns = ["portfolio_id", "date", "underlying_instrument_id", "portfolio_created_id"]
704
+ float_columns = [
705
+ "weighting",
706
+ "initial_currency_fx_rate",
707
+ "initial_shares",
708
+ "initial_price",
709
+ ]
710
+ df = portfolio.to_df().rename(
711
+ columns={
712
+ "currency_fx_rate": "initial_currency_fx_rate",
713
+ "shares": "initial_shares",
714
+ "price": "initial_price",
715
+ "currency": "currency_id",
716
+ "underlying_instrument": "underlying_instrument_id",
717
+ "portfolio_created": "portfolio_created_id",
718
+ "exchange": "exchange_id",
719
+ }
720
+ )
721
+ df["portfolio_id"] = self.id
722
+ df = df[index_columns + float_columns + ["is_estimated", "currency_id"]]
723
+ df[float_columns] = df[float_columns].astype("float")
724
+ df = df.groupby(index_columns, as_index=False, dropna=False).agg(
725
+ {
726
+ **{field: "first" for field in df.columns.difference(index_columns + float_columns)},
727
+ "weighting": "sum",
728
+ "initial_shares": "sum",
729
+ "initial_currency_fx_rate": "mean",
730
+ "initial_price": "mean",
731
+ }
732
+ )
733
+ df = df.replace([np.inf, -np.inf, np.nan], None)
734
+
735
+ for position in df.to_dict("records"):
736
+ obj, _ = AssetPosition.unannotated_objects.update_or_create(
737
+ portfolio_id=position["portfolio_id"],
738
+ date=position["date"],
739
+ underlying_instrument_id=position["underlying_instrument_id"],
740
+ portfolio_created_id=position["portfolio_created_id"],
741
+ defaults=position,
742
+ )
743
+ left_over_positions = left_over_positions.exclude(id=obj.id)
744
+ left_over_positions.delete()
745
+ if post_processing:
746
+ trigger_portfolio_change_as_task.delay(self.id, val_date)
747
+
748
+ def resynchronize_history(self, from_date: date, to_date: date, instrument: Instrument | None = None):
749
+ if (synchronisation_method := self.portfolio_synchronization) and self.assets.exists():
750
+ if not from_date:
751
+ from_date = self.assets.earliest("date").date
752
+ if not to_date:
753
+ to_date = self.assets.latest("date").date
754
+ # loop over every week day and trigger synchronization task in order
755
+ if to_date <= from_date:
756
+ raise ValueError("bound needs to be valid")
757
+ for sync_datetime in synchronisation_method.dates_range(from_date, to_date, filter_daily=True):
758
+ synchronisation_method.synchronize(
759
+ self, sync_datetime.date(), override_execution_datetime_validity=True
760
+ )
761
+ if instrument:
762
+ price_computation_method = None
763
+ try:
764
+ price_computation_method = Product.objects.get(id=instrument.id).price_computation
765
+ except Product.DoesNotExist:
766
+ with suppress(Index.DoesNotExist):
767
+ price_computation_method = Index.objects.get(id=instrument.id).price_computation
768
+ if price_computation_method and instrument.prices.exists():
769
+ if to_date <= from_date:
770
+ raise ValueError("bound needs to be valid")
771
+ if not from_date:
772
+ from_date = instrument.prices.earliest("date").date
773
+ if not to_date:
774
+ to_date = instrument.prices.latest("date").date
775
+ # loop over every week day and trigger synchronization task in order
776
+ for sync_datetime in price_computation_method.dates_range(from_date, to_date, filter_daily=True):
777
+ price_computation_method.compute(
778
+ instrument, sync_datetime.date(), override_execution_datetime_validity=True
779
+ )
780
+
781
+ def update_preferred_classification_per_instrument(self):
782
+ # Function to automatically assign Preferred instrument based on the assets' underlying instruments of the
783
+ # attached wbportfolio
784
+ instruments = filter(
785
+ None,
786
+ map(
787
+ lambda x: Instrument.objects.get(id=x["underlying_instrument"]).get_classifable_ancestor(
788
+ include_self=True
789
+ ),
790
+ self.assets.values("underlying_instrument").distinct("underlying_instrument"),
791
+ ),
792
+ )
793
+ leftovers_instruments = list(
794
+ PortfolioInstrumentPreferredClassificationThroughModel.objects.filter(portfolio=self).values_list(
795
+ "instrument", flat=True
796
+ )
797
+ )
798
+ for instrument in instruments:
799
+ other_classifications = instrument.classifications.filter(group__is_primary=False)
800
+ default_classification = None
801
+ if other_classifications.count() == 1:
802
+ default_classification = other_classifications.first()
803
+ if not PortfolioInstrumentPreferredClassificationThroughModel.objects.filter(
804
+ portfolio=self, instrument=instrument
805
+ ).exists():
806
+ PortfolioInstrumentPreferredClassificationThroughModel.objects.create(
807
+ portfolio=self,
808
+ instrument=instrument,
809
+ classification=default_classification,
810
+ classification_group=default_classification.group if default_classification else None,
811
+ )
812
+ if instrument.id in leftovers_instruments:
813
+ leftovers_instruments.remove(instrument.id)
814
+
815
+ for instrument_id in leftovers_instruments:
816
+ PortfolioInstrumentPreferredClassificationThroughModel.objects.filter(
817
+ portfolio=self, instrument=instrument_id
818
+ ).delete()
819
+
820
+ @classmethod
821
+ def get_endpoint_basename(cls):
822
+ return "wbportfolio:portfolio"
823
+
824
+ @classmethod
825
+ def get_representation_endpoint(cls):
826
+ return "wbportfolio:portfoliorepresentation-list"
827
+
828
+ @classmethod
829
+ def get_representation_value_key(cls):
830
+ return "id"
831
+
832
+ @classmethod
833
+ def get_representation_label_key(cls):
834
+ return "{{name}}"
835
+
836
+ @classmethod
837
+ def _get_or_create_portfolio(cls, instrument_handler, portfolio_data):
838
+ if isinstance(portfolio_data, int):
839
+ return Portfolio.objects.get(id=portfolio_data)
840
+ instrument = portfolio_data
841
+ if isinstance(portfolio_data, dict):
842
+ instrument = instrument_handler.process_object(instrument, only_security=False, read_only=True)[0]
843
+ return instrument.primary_portfolio
844
+
845
+ def check_share_diff(self, val_date: date) -> bool:
846
+ return self.assets.filter(Q(date=val_date) & ~Q(initial_shares=F("initial_shares_at_custodian"))).exists()
847
+
848
+ @classmethod
849
+ def get_contribution_df(
850
+ cls,
851
+ qs,
852
+ need_normalize=False,
853
+ groupby_label_id="underlying_security",
854
+ groubpy_label_title="underlying_instrument__name_repr",
855
+ currency_fx_rate_label="currency_fx_rate",
856
+ hedged_currency=None,
857
+ ):
858
+ # qs = AssetPosition.annotate_underlying_instrument(qs)
859
+ weight_label = "weighting" if not need_normalize else "total_value_fx_portfolio"
860
+ qs = qs.annotate(
861
+ is_hedged=Case(
862
+ When(
863
+ underlying_instrument__currency__isnull=False,
864
+ underlying_instrument__currency=hedged_currency,
865
+ then=Value(True),
866
+ ),
867
+ default=Value(False),
868
+ output_field=BooleanField(),
869
+ ),
870
+ coalesce_currency_fx_rate=Case(
871
+ When(is_hedged=True, then=Value(Decimal(1.0))),
872
+ default=F(currency_fx_rate_label),
873
+ output_field=models.BooleanField(),
874
+ ),
875
+ ).select_related("underlying_instrument")
876
+ df = pd.DataFrame(
877
+ qs.values(
878
+ "date",
879
+ "price",
880
+ "coalesce_currency_fx_rate",
881
+ groupby_label_id,
882
+ groubpy_label_title,
883
+ weight_label,
884
+ ),
885
+ columns=[
886
+ "date",
887
+ "price",
888
+ "coalesce_currency_fx_rate",
889
+ groupby_label_id,
890
+ groubpy_label_title,
891
+ weight_label,
892
+ ],
893
+ )
894
+ if not df.empty:
895
+ df = df[df[weight_label] != 0]
896
+ df.date = pd.to_datetime(df.date)
897
+ df["price_fx_portfolio"] = df.price * df.coalesce_currency_fx_rate
898
+
899
+ df[["price", "price_fx_portfolio", weight_label, "coalesce_currency_fx_rate"]] = df[
900
+ ["price", "price_fx_portfolio", weight_label, "coalesce_currency_fx_rate"]
901
+ ].astype("float")
902
+
903
+ df[groupby_label_id] = df[groupby_label_id].fillna(0)
904
+ df[groubpy_label_title] = df[groubpy_label_title].fillna("N/A")
905
+ df_static = df[[groupby_label_id, groubpy_label_title]].groupby(groupby_label_id, dropna=False).first()
906
+
907
+ df = (
908
+ df[
909
+ [
910
+ groupby_label_id,
911
+ "date",
912
+ "price",
913
+ "price_fx_portfolio",
914
+ weight_label,
915
+ "coalesce_currency_fx_rate",
916
+ ]
917
+ ]
918
+ .groupby(["date", groupby_label_id], dropna=False)
919
+ .agg(
920
+ {
921
+ "price": "mean",
922
+ "price_fx_portfolio": "mean",
923
+ weight_label: "sum",
924
+ "coalesce_currency_fx_rate": "mean",
925
+ }
926
+ )
927
+ .reset_index()
928
+ .set_index("date")
929
+ .sort_index()
930
+ )
931
+ df[weight_label] = df[weight_label].fillna(0)
932
+ value = df.pivot_table(
933
+ index="date",
934
+ columns=[groupby_label_id],
935
+ values=weight_label,
936
+ fill_value=0,
937
+ aggfunc="sum",
938
+ )
939
+ weights_ = value
940
+ if need_normalize:
941
+ total_value_price = df[weight_label].groupby("date", dropna=False).sum()
942
+ weights_ = value.divide(total_value_price, axis=0)
943
+ prices_usd = (
944
+ df.pivot_table(
945
+ index="date",
946
+ columns=[groupby_label_id],
947
+ values="price_fx_portfolio",
948
+ aggfunc="mean",
949
+ )
950
+ .replace(0, np.nan)
951
+ .bfill()
952
+ )
953
+
954
+ rates_fx = (
955
+ df.pivot_table(
956
+ index="date",
957
+ columns=[groupby_label_id],
958
+ values="coalesce_currency_fx_rate",
959
+ aggfunc="mean",
960
+ )
961
+ .replace(0, np.nan)
962
+ .bfill()
963
+ )
964
+
965
+ prices_usd = prices_usd.ffill()
966
+ performance_prices = prices_usd / prices_usd.shift(1, axis=0) - 1
967
+ contributions_prices = performance_prices.multiply(weights_.shift(1, axis=0)).dropna(how="all")
968
+ total_contrib_prices = (1 + contributions_prices.sum(axis=1)).shift(1, fill_value=1.0).cumprod()
969
+ contributions_prices = contributions_prices.multiply(total_contrib_prices, axis=0).sum(skipna=False)
970
+ monthly_perf_prices = (1 + performance_prices).dropna(how="all").product(axis=0, skipna=False) - 1
971
+
972
+ rates_fx = rates_fx.ffill()
973
+ performance_rates_fx = rates_fx / rates_fx.shift(1, axis=0) - 1
974
+ contributions_rates_fx = performance_rates_fx.multiply(weights_.shift(1, axis=0)).dropna(how="all")
975
+ total_contrib_rates_fx = (1 + contributions_rates_fx.sum(axis=1)).shift(1, fill_value=1.0).cumprod()
976
+ contributions_rates_fx = contributions_rates_fx.multiply(total_contrib_rates_fx, axis=0).sum(skipna=False)
977
+ monthly_perf_rates_fx = (1 + performance_rates_fx).dropna(how="all").product(axis=0, skipna=False) - 1
978
+
979
+ res = pd.concat(
980
+ [
981
+ df_static,
982
+ monthly_perf_prices,
983
+ monthly_perf_rates_fx,
984
+ contributions_prices,
985
+ contributions_rates_fx,
986
+ weights_.iloc[0, :],
987
+ weights_.iloc[-1, :],
988
+ value.iloc[0, :],
989
+ value.iloc[-1, :],
990
+ ],
991
+ axis=1,
992
+ ).reset_index()
993
+ res.columns = [
994
+ groupby_label_id,
995
+ groubpy_label_title,
996
+ "performance_total",
997
+ "performance_forex",
998
+ "contribution_total",
999
+ "contribution_forex",
1000
+ "allocation_start",
1001
+ "allocation_end",
1002
+ "total_value_start",
1003
+ "total_value_end",
1004
+ ]
1005
+
1006
+ return res.replace([np.inf, -np.inf, np.nan], 0)
1007
+ return pd.DataFrame()
1008
+
1009
+
1010
+ @receiver(post_save, sender="wbportfolio.Product")
1011
+ @receiver(post_save, sender="wbportfolio.ProductGroup")
1012
+ @receiver(post_save, sender="wbportfolio.Index")
1013
+ def post_product_creation(sender, instance, created, raw, **kwargs):
1014
+ if not raw and (created or not InstrumentPortfolioThroughModel.objects.filter(instrument=instance).exists()):
1015
+ portfolio = Portfolio.objects.create(
1016
+ name=f"Portfolio: {instance.name}",
1017
+ currency=instance.currency,
1018
+ invested_timespan=DateRange(instance.inception_date if instance.inception_date else date.min, date.max),
1019
+ )
1020
+ InstrumentPortfolioThroughModel.objects.get_or_create(instrument=instance, defaults={"portfolio": portfolio})
1021
+
1022
+
1023
+ @shared_task(queue="portfolio")
1024
+ def resynchronize_history_as_task(portfolio_id: int, from_date: date, to_date: date, instrument_id: int | None = None):
1025
+ portfolio = Portfolio.objects.get(id=portfolio_id)
1026
+ instrument = Instrument.objects.get(id=instrument_id) if instrument_id else None
1027
+ portfolio.resynchronize_history(from_date, to_date, instrument=instrument)
1028
+
1029
+
1030
+ @shared_task(queue="portfolio")
1031
+ def trigger_portfolio_change_as_task(portfolio_id, val_date, **kwargs):
1032
+ portfolio = Portfolio.all_objects.get(id=portfolio_id)
1033
+ portfolio.change_at_date(val_date, **kwargs)
1034
+
1035
+
1036
+ @shared_task(queue="portfolio")
1037
+ def propagate_or_update_portfolio_assets_as_task(portfolio_id, from_date, to_date, **kwargs):
1038
+ portfolio = Portfolio.objects.get(id=portfolio_id)
1039
+ portfolio.propagate_or_update_assets(from_date, to_date, **kwargs)