wbportfolio 1.52.0__py2.py3-none-any.whl → 1.59.4__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wbportfolio might be problematic. Click here for more details.

Files changed (273) hide show
  1. wbportfolio/admin/__init__.py +3 -1
  2. wbportfolio/admin/indexes.py +1 -1
  3. wbportfolio/admin/orders/__init__.py +2 -0
  4. wbportfolio/admin/orders/order_proposals.py +16 -0
  5. wbportfolio/admin/orders/orders.py +32 -0
  6. wbportfolio/admin/portfolio.py +11 -5
  7. wbportfolio/admin/product_groups.py +1 -1
  8. wbportfolio/admin/products.py +2 -1
  9. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  10. wbportfolio/admin/transactions/__init__.py +0 -2
  11. wbportfolio/admin/transactions/dividends.py +40 -4
  12. wbportfolio/admin/transactions/fees.py +24 -14
  13. wbportfolio/admin/transactions/trades.py +34 -27
  14. wbportfolio/analysis/claims.py +5 -6
  15. wbportfolio/api_clients/ubs.py +162 -0
  16. wbportfolio/constants.py +1 -0
  17. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  18. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  19. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  20. wbportfolio/contrib/company_portfolio/models.py +69 -39
  21. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  22. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  23. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  24. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  25. wbportfolio/defaults/fees/default.py +7 -15
  26. wbportfolio/factories/__init__.py +2 -2
  27. wbportfolio/factories/assets.py +1 -1
  28. wbportfolio/factories/dividends.py +8 -3
  29. wbportfolio/factories/fees.py +8 -4
  30. wbportfolio/factories/orders/__init__.py +2 -0
  31. wbportfolio/factories/orders/order_proposals.py +21 -0
  32. wbportfolio/factories/orders/orders.py +34 -0
  33. wbportfolio/factories/portfolios.py +2 -1
  34. wbportfolio/factories/product_groups.py +3 -3
  35. wbportfolio/factories/products.py +3 -3
  36. wbportfolio/factories/rebalancing.py +1 -1
  37. wbportfolio/factories/trades.py +12 -16
  38. wbportfolio/filters/assets.py +18 -4
  39. wbportfolio/filters/orders/__init__.py +2 -0
  40. wbportfolio/filters/orders/order_proposals.py +55 -0
  41. wbportfolio/filters/orders/orders.py +11 -0
  42. wbportfolio/filters/portfolios.py +38 -1
  43. wbportfolio/filters/positions.py +0 -1
  44. wbportfolio/filters/transactions/__init__.py +1 -2
  45. wbportfolio/filters/transactions/fees.py +5 -12
  46. wbportfolio/filters/transactions/trades.py +16 -8
  47. wbportfolio/filters/transactions/utils.py +42 -0
  48. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  49. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  50. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  51. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  52. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  53. wbportfolio/import_export/backends/utils.py +0 -17
  54. wbportfolio/import_export/handlers/asset_position.py +22 -10
  55. wbportfolio/import_export/handlers/dividend.py +8 -8
  56. wbportfolio/import_export/handlers/fees.py +13 -23
  57. wbportfolio/import_export/handlers/orders.py +71 -0
  58. wbportfolio/import_export/handlers/trade.py +53 -77
  59. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  60. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  61. wbportfolio/import_export/parsers/jpmorgan/fees.py +4 -4
  62. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  63. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  64. wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
  65. wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
  66. wbportfolio/import_export/parsers/leonteq/trade.py +2 -6
  67. wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
  68. wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
  69. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  70. wbportfolio/import_export/parsers/natixis/fees.py +7 -9
  71. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  72. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
  73. wbportfolio/import_export/parsers/sg_lux/equity.py +10 -10
  74. wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
  75. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
  76. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  77. wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
  78. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  79. wbportfolio/import_export/parsers/societe_generale/strategy.py +5 -5
  80. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  81. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  82. wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
  83. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  84. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  85. wbportfolio/import_export/parsers/ubs/equity.py +3 -2
  86. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  87. wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
  88. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
  89. wbportfolio/import_export/parsers/vontobel/management_fees.py +12 -20
  90. wbportfolio/import_export/parsers/vontobel/performance_fees.py +5 -8
  91. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  92. wbportfolio/import_export/resources/trades.py +3 -3
  93. wbportfolio/import_export/utils.py +3 -1
  94. wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
  95. wbportfolio/metric/backends/base.py +2 -2
  96. wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
  97. wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
  98. wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
  99. wbportfolio/migrations/0079_alter_trade_drift_factor.py +19 -0
  100. wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
  101. wbportfolio/migrations/0081_alter_trade_drift_factor.py +19 -0
  102. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  103. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  104. wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
  105. wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
  106. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  107. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  108. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  109. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  110. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  111. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  112. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  113. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  114. wbportfolio/models/__init__.py +2 -0
  115. wbportfolio/models/adjustments.py +1 -1
  116. wbportfolio/models/asset.py +28 -170
  117. wbportfolio/models/builder.py +323 -0
  118. wbportfolio/models/custodians.py +3 -3
  119. wbportfolio/models/exceptions.py +1 -1
  120. wbportfolio/models/graphs/portfolio.py +1 -1
  121. wbportfolio/models/graphs/utils.py +11 -11
  122. wbportfolio/models/mixins/instruments.py +7 -0
  123. wbportfolio/models/mixins/liquidity_stress_test.py +4 -4
  124. wbportfolio/models/orders/__init__.py +2 -0
  125. wbportfolio/models/orders/order_proposals.py +1414 -0
  126. wbportfolio/models/orders/orders.py +410 -0
  127. wbportfolio/models/portfolio.py +311 -289
  128. wbportfolio/models/portfolio_relationship.py +6 -0
  129. wbportfolio/models/products.py +12 -0
  130. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +40 -27
  131. wbportfolio/models/roles.py +4 -10
  132. wbportfolio/models/transactions/__init__.py +0 -4
  133. wbportfolio/models/transactions/claim.py +7 -6
  134. wbportfolio/models/transactions/dividends.py +42 -5
  135. wbportfolio/models/transactions/fees.py +55 -22
  136. wbportfolio/models/transactions/trades.py +121 -442
  137. wbportfolio/models/transactions/transactions.py +78 -158
  138. wbportfolio/models/utils.py +100 -1
  139. wbportfolio/order_routing/__init__.py +35 -0
  140. wbportfolio/order_routing/adapters/__init__.py +65 -0
  141. wbportfolio/order_routing/adapters/ubs.py +195 -0
  142. wbportfolio/order_routing/router.py +33 -0
  143. wbportfolio/order_routing/tests/__init__.py +0 -0
  144. wbportfolio/order_routing/tests/test_router.py +110 -0
  145. wbportfolio/permissions.py +7 -0
  146. wbportfolio/pms/analytics/portfolio.py +17 -9
  147. wbportfolio/pms/analytics/utils.py +9 -0
  148. wbportfolio/pms/trading/__init__.py +0 -1
  149. wbportfolio/pms/trading/optimizer.py +61 -0
  150. wbportfolio/pms/typing.py +198 -63
  151. wbportfolio/rebalancing/base.py +12 -1
  152. wbportfolio/rebalancing/decorators.py +1 -1
  153. wbportfolio/rebalancing/models/composite.py +4 -8
  154. wbportfolio/rebalancing/models/equally_weighted.py +13 -11
  155. wbportfolio/rebalancing/models/market_capitalization_weighted.py +21 -14
  156. wbportfolio/rebalancing/models/model_portfolio.py +14 -18
  157. wbportfolio/risk_management/backends/__init__.py +1 -0
  158. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  159. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  160. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  161. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  162. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  163. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  164. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  165. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  166. wbportfolio/serializers/__init__.py +1 -0
  167. wbportfolio/serializers/orders/__init__.py +2 -0
  168. wbportfolio/serializers/orders/order_proposals.py +115 -0
  169. wbportfolio/serializers/orders/orders.py +283 -0
  170. wbportfolio/serializers/portfolios.py +7 -7
  171. wbportfolio/serializers/positions.py +2 -2
  172. wbportfolio/serializers/rebalancing.py +1 -1
  173. wbportfolio/serializers/signals.py +9 -12
  174. wbportfolio/serializers/transactions/__init__.py +1 -10
  175. wbportfolio/serializers/transactions/claim.py +2 -2
  176. wbportfolio/serializers/transactions/dividends.py +37 -9
  177. wbportfolio/serializers/transactions/fees.py +39 -10
  178. wbportfolio/serializers/transactions/trades.py +55 -157
  179. wbportfolio/tasks.py +43 -5
  180. wbportfolio/tests/analysis/__init__.py +0 -0
  181. wbportfolio/tests/analysis/test_claims.py +85 -0
  182. wbportfolio/tests/conftest.py +12 -12
  183. wbportfolio/tests/models/orders/__init__.py +0 -0
  184. wbportfolio/tests/models/orders/test_order_proposals.py +1046 -0
  185. wbportfolio/tests/models/test_assets.py +7 -3
  186. wbportfolio/tests/models/test_imports.py +9 -13
  187. wbportfolio/tests/models/test_portfolios.py +102 -95
  188. wbportfolio/tests/models/test_products.py +11 -0
  189. wbportfolio/tests/models/test_splits.py +1 -6
  190. wbportfolio/tests/models/test_utils.py +140 -0
  191. wbportfolio/tests/models/transactions/test_fees.py +7 -13
  192. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  193. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  194. wbportfolio/tests/pms/test_analytics.py +22 -3
  195. wbportfolio/tests/rebalancing/test_models.py +51 -57
  196. wbportfolio/tests/signals.py +10 -20
  197. wbportfolio/tests/tests.py +3 -1
  198. wbportfolio/tests/viewsets/test_products.py +1 -0
  199. wbportfolio/urls.py +10 -13
  200. wbportfolio/viewsets/__init__.py +9 -4
  201. wbportfolio/viewsets/assets.py +3 -204
  202. wbportfolio/viewsets/charts/__init__.py +6 -1
  203. wbportfolio/viewsets/charts/assets.py +344 -154
  204. wbportfolio/viewsets/configs/buttons/__init__.py +2 -2
  205. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  206. wbportfolio/viewsets/configs/buttons/mixins.py +4 -4
  207. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  208. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  209. wbportfolio/viewsets/configs/display/__init__.py +2 -5
  210. wbportfolio/viewsets/configs/display/assets.py +6 -19
  211. wbportfolio/viewsets/configs/display/fees.py +3 -3
  212. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  213. wbportfolio/viewsets/configs/display/products.py +1 -1
  214. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  215. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  216. wbportfolio/viewsets/configs/display/trades.py +1 -189
  217. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -7
  218. wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
  219. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  220. wbportfolio/viewsets/configs/menu/__init__.py +1 -1
  221. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  222. wbportfolio/viewsets/configs/titles/__init__.py +2 -3
  223. wbportfolio/viewsets/configs/titles/fees.py +4 -8
  224. wbportfolio/viewsets/esg.py +3 -5
  225. wbportfolio/viewsets/mixins.py +5 -1
  226. wbportfolio/viewsets/orders/__init__.py +6 -0
  227. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  228. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  229. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +188 -0
  230. wbportfolio/viewsets/orders/configs/buttons/orders.py +113 -0
  231. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  232. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +157 -0
  233. wbportfolio/viewsets/orders/configs/displays/orders.py +232 -0
  234. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  235. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  236. wbportfolio/viewsets/orders/configs/endpoints/orders.py +28 -0
  237. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  238. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  239. wbportfolio/viewsets/orders/order_proposals.py +252 -0
  240. wbportfolio/viewsets/orders/orders.py +277 -0
  241. wbportfolio/viewsets/portfolios.py +36 -12
  242. wbportfolio/viewsets/positions.py +3 -2
  243. wbportfolio/viewsets/products.py +6 -6
  244. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  245. wbportfolio/viewsets/transactions/__init__.py +3 -14
  246. wbportfolio/viewsets/transactions/fees.py +22 -22
  247. wbportfolio/viewsets/transactions/trades.py +1 -180
  248. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +3 -1
  249. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +252 -203
  250. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  251. wbportfolio/admin/transactions/transactions.py +0 -38
  252. wbportfolio/factories/transactions.py +0 -22
  253. wbportfolio/fdm/tasks.py +0 -13
  254. wbportfolio/filters/transactions/transactions.py +0 -99
  255. wbportfolio/models/transactions/expiry.py +0 -7
  256. wbportfolio/models/transactions/trade_proposals.py +0 -704
  257. wbportfolio/pms/trading/handler.py +0 -161
  258. wbportfolio/serializers/transactions/expiry.py +0 -18
  259. wbportfolio/serializers/transactions/trade_proposals.py +0 -76
  260. wbportfolio/serializers/transactions/transactions.py +0 -85
  261. wbportfolio/tests/models/transactions/test_trade_proposals.py +0 -410
  262. wbportfolio/viewsets/configs/buttons/trade_proposals.py +0 -66
  263. wbportfolio/viewsets/configs/display/trade_proposals.py +0 -100
  264. wbportfolio/viewsets/configs/display/transactions.py +0 -55
  265. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  266. wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
  267. wbportfolio/viewsets/configs/menu/transactions.py +0 -9
  268. wbportfolio/viewsets/configs/titles/transactions.py +0 -9
  269. wbportfolio/viewsets/signals.py +0 -43
  270. wbportfolio/viewsets/transactions/trade_proposals.py +0 -139
  271. wbportfolio/viewsets/transactions/transactions.py +0 -122
  272. /wbportfolio/{fdm → api_clients}/__init__.py +0 -0
  273. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
wbportfolio/pms/typing.py CHANGED
@@ -1,10 +1,15 @@
1
+ import enum
1
2
  from dataclasses import asdict, dataclass, field, fields
3
+ from datetime import date
2
4
  from datetime import date as date_lib
3
5
  from decimal import Decimal
6
+ from typing import Self
4
7
 
5
8
  import pandas as pd
6
9
  from django.core.exceptions import ValidationError
7
10
 
11
+ from wbportfolio.order_routing import ExecutionInstruction
12
+
8
13
 
9
14
  @dataclass(frozen=True)
10
15
  class Valuation:
@@ -13,12 +18,13 @@ class Valuation:
13
18
  outstanding_shares: Decimal = Decimal(0)
14
19
 
15
20
 
16
- @dataclass(frozen=True)
21
+ @dataclass()
17
22
  class Position:
18
23
  underlying_instrument: int
19
24
  weighting: Decimal
20
25
  date: date_lib
21
26
 
27
+ daily_return: Decimal = Decimal("0")
22
28
  currency: int | None = None
23
29
  instrument_type: int | None = None
24
30
  asset_valuation_date: date_lib | None = None
@@ -37,6 +43,10 @@ class Position:
37
43
  volume_usd: float = None
38
44
  price: float = None
39
45
 
46
+ def __post_init__(self):
47
+ self.daily_return = round(self.daily_return, 16)
48
+ self.weighting = round(self.weighting, 8)
49
+
40
50
  def __add__(self, other):
41
51
  return Position(
42
52
  weighting=self.weighting + other.weighting,
@@ -44,59 +54,70 @@ class Position:
44
54
  **{f.name: getattr(self, f.name) for f in fields(Position) if f.name not in ["weighting", "shares"]},
45
55
  )
46
56
 
47
-
48
- @dataclass(frozen=True)
49
- class Portfolio:
50
- positions: tuple[Position] | tuple
51
- positions_map: dict[Position] = field(init=False, repr=False)
52
-
53
- def __post_init__(self):
54
- positions_map = {}
55
- for pos in self.positions:
56
- if pos.underlying_instrument in positions_map:
57
- positions_map[pos.underlying_instrument] += pos
58
- else:
59
- positions_map[pos.underlying_instrument] = pos
60
- object.__setattr__(self, "positions_map", positions_map)
61
-
62
- @property
63
- def total_weight(self):
64
- return round(sum([pos.weighting for pos in self.positions]), 6)
65
-
66
- @property
67
- def total_shares(self):
68
- return sum([pos.target_shares for pos in self.positions if pos.target_shares is not None])
69
-
70
- def to_df(self):
71
- return pd.DataFrame([asdict(pos) for pos in self.positions])
72
-
73
- def to_dict(self) -> dict[int, Decimal]:
74
- return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
75
-
76
- def __len__(self):
77
- return len(self.positions)
57
+ def copy(self, **kwargs):
58
+ attrs = {f.name: getattr(self, f.name) for f in fields(Position)}
59
+ attrs.update(kwargs)
60
+ return Position(**attrs)
78
61
 
79
62
 
80
63
  @dataclass(frozen=True)
64
+ class Order:
65
+ class AssetType(enum.Enum):
66
+ EQUITY = "EQUITY"
67
+ AMERICAN_DEPOSITORY_RECEIPT = "AMERICAN_DEPOSITORY_RECEIPT"
68
+
69
+ id: int | str
70
+ trade_date: date
71
+ target_weight: float
72
+
73
+ # Instrument identifier
74
+ asset_class: AssetType
75
+ refinitiv_identifier_code: str | None = None
76
+ bloomberg_ticker: str | None = None
77
+ sedol: str | None = None
78
+
79
+ weighting: float | None = None
80
+ target_shares: float | None = None
81
+ shares: float | None = None
82
+ execution_price: float | None = None
83
+ execution_instruction: ExecutionInstruction = ExecutionInstruction.MARKET_ON_CLOSE.value
84
+ execution_instruction_parameters: dict | None = None
85
+ comment: str = ""
86
+
87
+
88
+ @dataclass()
81
89
  class Trade:
82
90
  underlying_instrument: int
83
- instrument_type: str
91
+ instrument_type: int
84
92
  currency: int
85
93
  date: date_lib
86
-
94
+ price: Decimal
87
95
  effective_weight: Decimal
88
96
  target_weight: Decimal
97
+ currency_fx_rate: Decimal = Decimal("1")
98
+ effective_shares: Decimal = Decimal("0")
99
+ target_shares: Decimal = Decimal("0")
100
+ daily_return: Decimal = Decimal("0")
101
+ effective_quantization_error: Decimal = Decimal("0")
102
+ target_quantization_error: Decimal = Decimal("0")
103
+
89
104
  id: int | None = None
90
- effective_shares: Decimal = None
105
+ is_cash: bool = False
106
+
107
+ def __post_init__(self):
108
+ self.effective_weight = round(self.effective_weight, 8)
109
+ # ensure a trade target weight cannot be lower than 0
110
+ self.target_weight = max(round(self.target_weight, 8), Decimal("0"))
111
+ self.daily_return = round(self.daily_return, 16)
91
112
 
92
113
  def __add__(self, other):
93
114
  return Trade(
94
115
  underlying_instrument=self.underlying_instrument,
95
- effective_weight=self.effective_weight + other.effective_weight,
116
+ effective_weight=self.effective_weight,
96
117
  target_weight=self.target_weight + other.target_weight,
97
- effective_shares=self.effective_shares + other.effective_shares
98
- if (self.effective_shares is not None and other.effective_shares is not None)
99
- else None,
118
+ effective_shares=self.effective_shares,
119
+ target_shares=self.target_shares + other.target_shares,
120
+ daily_return=self.daily_return,
100
121
  **{
101
122
  f.name: getattr(self, f.name)
102
123
  for f in fields(Trade)
@@ -105,14 +126,29 @@ class Trade:
105
126
  "effective_weight",
106
127
  "target_weight",
107
128
  "effective_shares",
129
+ "target_shares",
108
130
  "underlying_instrument",
131
+ "daily_return",
109
132
  ]
110
133
  },
111
134
  )
112
135
 
136
+ def copy(self, **kwargs):
137
+ attrs = {f.name: getattr(self, f.name) for f in fields(Trade)}
138
+ attrs.update(kwargs)
139
+ return Trade(**attrs)
140
+
113
141
  @property
114
142
  def delta_weight(self) -> Decimal:
115
- return self.target_weight - self.effective_weight
143
+ return (self.target_weight + self.target_quantization_error) - self.effective_weight
144
+
145
+ @property
146
+ def delta_shares(self) -> Decimal:
147
+ return self.target_shares - self.effective_shares
148
+
149
+ @property
150
+ def price_fx_portfolio(self) -> Decimal:
151
+ return self.price * self.currency_fx_rate
116
152
 
117
153
  def validate(self):
118
154
  return True
@@ -121,21 +157,30 @@ class Trade:
121
157
  # if self.target_weight < 0 or self.target_weight > 1.0:
122
158
  # raise ValidationError("Target Weight needs to be in range [0, 1]")
123
159
 
124
- def normalize_target(self, total_target_weight: Decimal):
125
- t = Trade(
126
- target_weight=self.target_weight / total_target_weight if total_target_weight else self.target_weight,
127
- **{f.name: getattr(self, f.name) for f in fields(Trade) if f.name not in ["target_weight"]},
128
- )
129
- return t
160
+ def normalize_target(
161
+ self, factor: Decimal | None = None, target_shares: Decimal | int | None = None, target_weight: Decimal = None
162
+ ):
163
+ if factor is None:
164
+ if target_shares is not None:
165
+ factor = target_shares / self.target_shares if self.target_shares else Decimal("1")
166
+ elif target_weight is not None:
167
+ factor = target_weight / self.target_weight if self.target_weight else Decimal("1")
168
+ else:
169
+ raise ValueError("Target weight and shares cannot be both None")
170
+ return self.copy(target_weight=self.target_weight * factor, target_shares=self.target_shares * factor)
130
171
 
131
172
 
132
173
  @dataclass(frozen=True)
133
174
  class TradeBatch:
134
- trades: tuple[Trade]
175
+ trades: list[Trade]
135
176
  trades_map: dict[Trade] = field(init=False, repr=False)
136
177
 
137
178
  def __post_init__(self):
138
179
  trade_map = {}
180
+ if self.total_effective_weight and (quant_error := Decimal("1") - self.total_effective_weight):
181
+ self.largest_effective_order.effective_quantization_error = quant_error
182
+ if self.total_target_weight and (quant_error := Decimal("1") - self.total_target_weight):
183
+ self.largest_effective_order.target_quantization_error = quant_error
139
184
  for trade in self.trades:
140
185
  if trade.underlying_instrument in trade_map:
141
186
  trade_map[trade.underlying_instrument] += trade
@@ -143,17 +188,21 @@ class TradeBatch:
143
188
  trade_map[trade.underlying_instrument] = trade
144
189
  object.__setattr__(self, "trades_map", trade_map)
145
190
 
191
+ @property
192
+ def largest_effective_order(self) -> Trade:
193
+ return max(self.trades, key=lambda obj: obj.effective_weight)
194
+
146
195
  @property
147
196
  def total_target_weight(self) -> Decimal:
148
- return round(sum([trade.target_weight for trade in self.trades]), 6)
197
+ return sum([trade.target_weight for trade in self.trades], Decimal("0"))
149
198
 
150
199
  @property
151
200
  def total_effective_weight(self) -> Decimal:
152
- return round(sum([trade.effective_weight for trade in self.trades]), 6)
201
+ return sum([trade.effective_weight for trade in self.trades], Decimal("0"))
153
202
 
154
203
  @property
155
- def totat_abs_delta_weight(self) -> Decimal:
156
- return sum([abs(trade.delta_weight) for trade in self.trades])
204
+ def total_abs_delta_weight(self) -> Decimal:
205
+ return sum([abs(trade.delta_weight) for trade in self.trades], Decimal("0"))
157
206
 
158
207
  def __add__(self, other):
159
208
  return TradeBatch(tuple(self.trades + other.trades))
@@ -161,20 +210,106 @@ class TradeBatch:
161
210
  def __len__(self):
162
211
  return len(self.trades)
163
212
 
213
+ def __iter__(self):
214
+ return iter(self.trades_map.values())
215
+
164
216
  def validate(self):
165
217
  if round(float(self.total_target_weight), 4) != 1: # we do that to remove decimal over precision
166
218
  raise ValidationError(f"Total Weight cannot be different than 1 ({float(self.total_target_weight)})")
167
219
 
168
- def convert_to_portfolio(self):
169
- positions = []
170
- for instrument, trade in self.trades_map.items():
171
- positions.append(
172
- Position(
173
- underlying_instrument=trade.underlying_instrument,
174
- instrument_type=trade.instrument_type,
175
- weighting=trade.target_weight,
176
- currency=trade.currency,
177
- date=trade.date,
178
- )
220
+
221
+ @dataclass(frozen=True)
222
+ class Portfolio:
223
+ positions: list[Position] | list
224
+ positions_map: dict[int, Position] = field(init=False, repr=False)
225
+
226
+ def __post_init__(self):
227
+ positions_map = {}
228
+
229
+ for pos in self.positions:
230
+ if pos.underlying_instrument in positions_map:
231
+ positions_map[pos.underlying_instrument] += pos
232
+ else:
233
+ positions_map[pos.underlying_instrument] = pos
234
+ object.__setattr__(self, "positions_map", positions_map)
235
+
236
+ @property
237
+ def total_weight(self):
238
+ return sum([pos.weighting for pos in self.positions])
239
+
240
+ @property
241
+ def total_shares(self):
242
+ return sum([pos.target_shares for pos in self.positions if pos.target_shares is not None])
243
+
244
+ def to_df(self, exclude_cash: bool = False) -> pd.DataFrame:
245
+ return pd.DataFrame([asdict(pos) for pos in self.positions if not exclude_cash or not pos.is_cash])
246
+
247
+ def to_dict(self) -> dict[int, Decimal]:
248
+ return {underlying_instrument: pos.weighting for underlying_instrument, pos in self.positions_map.items()}
249
+
250
+ def get_orders(self, target_portfolio: Self) -> TradeBatch:
251
+ instruments = self.positions_map.copy()
252
+ instruments.update(target_portfolio.positions_map)
253
+
254
+ trades: list[Trade] = []
255
+ for instrument_id, pos in instruments.items():
256
+ effective_weight = target_weight = 0
257
+ effective_shares = target_shares = 0
258
+ daily_return = 0
259
+ price = Decimal("0")
260
+ is_cash = False
261
+ trade_date = None
262
+ if effective_pos := self.positions_map.get(instrument_id, None):
263
+ effective_weight = effective_pos.weighting
264
+ effective_shares = effective_pos.shares
265
+ daily_return = effective_pos.daily_return
266
+ is_cash = effective_pos.is_cash
267
+ price = effective_pos.price
268
+ trade_date = effective_pos.date
269
+
270
+ if target_pos := target_portfolio.positions_map.get(instrument_id, None):
271
+ target_weight = target_pos.weighting
272
+ is_cash = target_pos.is_cash
273
+ if target_pos.shares is not None:
274
+ target_shares = target_pos.shares
275
+ if target_pos.price:
276
+ price = target_pos.price
277
+ trade_date = target_pos.date
278
+
279
+ trade = Trade(
280
+ underlying_instrument=instrument_id,
281
+ effective_weight=effective_weight,
282
+ target_weight=target_weight,
283
+ effective_shares=effective_shares,
284
+ target_shares=target_shares,
285
+ date=trade_date,
286
+ instrument_type=pos.instrument_type,
287
+ currency=pos.currency,
288
+ price=Decimal(price) if price else Decimal("0"),
289
+ currency_fx_rate=Decimal(pos.currency_fx_rate),
290
+ daily_return=Decimal(daily_return),
291
+ is_cash=is_cash,
179
292
  )
180
- return Portfolio(tuple(positions))
293
+ trades.append(trade)
294
+ return TradeBatch(trades)
295
+
296
+ def __len__(self):
297
+ return len(self.positions)
298
+
299
+ def __bool__(self):
300
+ return len(self.positions) > 0
301
+
302
+ def normalize_cash(self, target_cash_weight: Decimal):
303
+ """
304
+ Normalize the instantiate portfolio so that the sum of the cash position equals to the new target cash position
305
+ """
306
+ positions = list(filter(lambda pos: not pos.is_cash, self.positions))
307
+ cash_position = next(filter(lambda pos: pos.is_cash, self.positions), None)
308
+ total_non_cash_weight = sum(map(lambda pos: pos.weighting, positions))
309
+ target_weight = Decimal("1") - target_cash_weight
310
+ for pos in positions:
311
+ pos.weighting = pos.weighting * target_weight / total_non_cash_weight
312
+ if cash_position:
313
+ cash_position.weighting = target_cash_weight
314
+ positions.append(cash_position)
315
+ return Portfolio(positions)
@@ -8,10 +8,21 @@ class AbstractRebalancingModel:
8
8
  def validation_errors(self) -> str:
9
9
  return getattr(self, "_validation_errors", "Rebalacing cannot applied for these parameters")
10
10
 
11
- def __init__(self, portfolio, trade_date: date, last_effective_date: date, **kwargs):
11
+ def __init__(
12
+ self,
13
+ portfolio,
14
+ trade_date: date,
15
+ last_effective_date: date,
16
+ effective_portfolio: PortfolioDTO | None = None,
17
+ **kwargs,
18
+ ):
12
19
  self.portfolio = portfolio
13
20
  self.trade_date = trade_date
14
21
  self.last_effective_date = last_effective_date
22
+ self.effective_portfolio = effective_portfolio
23
+ # we try to get the portfolio at the trade date
24
+ if not self.effective_portfolio:
25
+ self.effective_portfolio = self.portfolio._build_dto(self.last_effective_date)
15
26
 
16
27
  def is_valid(self) -> bool:
17
28
  return True
@@ -2,7 +2,7 @@ def register(model_name: str):
2
2
  """
3
3
  Decorator to include when a backend need automatic registration
4
4
  """
5
- from wbportfolio.models.transactions.rebalancing import RebalancingModel
5
+ from wbportfolio.models.rebalancing import RebalancingModel
6
6
 
7
7
  def _decorator(backend_class):
8
8
  defaults = {
@@ -3,7 +3,6 @@ from decimal import Decimal
3
3
  from django.core.exceptions import ObjectDoesNotExist
4
4
  from wbfdm.models import InstrumentPrice
5
5
 
6
- from wbportfolio.models import Trade
7
6
  from wbportfolio.pms.typing import Portfolio, Position
8
7
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
9
8
  from wbportfolio.rebalancing.decorators import register
@@ -14,21 +13,18 @@ class CompositeRebalancing(AbstractRebalancingModel):
14
13
  @property
15
14
  def base_assets(self) -> dict[int, Decimal]:
16
15
  """
17
- Return a dictionary representation (instrument_id: target weight) of this trade proposal
16
+ Return a dictionary representation (instrument_id: target weight) of this order proposal
18
17
  Returns:
19
18
  A dictionary representation
20
19
 
21
20
  """
22
21
  try:
23
- latest_trade_proposal = self.portfolio.trade_proposals.filter(
24
- status="APPROVED", trade_date__lt=self.trade_date
22
+ latest_order_proposal = self.portfolio.order_proposals.filter(
23
+ status="CONFIRMED", trade_date__lt=self.trade_date
25
24
  ).latest("trade_date")
26
25
  return {
27
26
  v["underlying_instrument"]: v["target_weight"]
28
- for v in latest_trade_proposal.trades.all()
29
- .annotate_base_info()
30
- .filter(status=Trade.Status.EXECUTED)
31
- .values("underlying_instrument", "target_weight")
27
+ for v in latest_order_proposal.get_orders().values("underlying_instrument", "target_weight")
32
28
  }
33
29
  except ObjectDoesNotExist:
34
30
  return dict()
@@ -10,23 +10,25 @@ from wbportfolio.rebalancing.decorators import register
10
10
  @register("Equally Weighted Rebalancing")
11
11
  class EquallyWeightedRebalancing(AbstractRebalancingModel):
12
12
  def __init__(self, *args, **kwargs):
13
- super().__init__(*args, **kwargs)
14
- self.assets = self.portfolio.assets.filter(date=self.last_effective_date)
13
+ super(EquallyWeightedRebalancing, self).__init__(*args, **kwargs)
14
+ if not self.effective_portfolio:
15
+ self.effective_portfolio = self.portfolio._build_dto(self.trade_date)
15
16
 
16
17
  def is_valid(self) -> bool:
17
18
  return (
18
- self.assets.exists()
19
+ len(self.effective_portfolio.positions) > 0
19
20
  and InstrumentPrice.objects.filter(
20
- date=self.trade_date, instrument__in=self.assets.values("underlying_quote")
21
+ date=self.trade_date, instrument__in=self.effective_portfolio.positions_map.keys()
21
22
  ).exists()
22
23
  )
23
24
 
24
25
  def get_target_portfolio(self) -> Portfolio:
25
26
  positions = []
26
- assets = self.portfolio.assets.filter(date=self.last_effective_date)
27
- nb_assets = assets.count()
28
- for asset in assets:
29
- asset.date = self.trade_date
30
- asset.asset_valuation_date = self.trade_date
31
- positions.append(asset._build_dto(new_weight=Decimal(1 / nb_assets)))
32
- return Portfolio(positions=tuple(positions))
27
+ assets = list(filter(lambda p: not p.is_cash, self.effective_portfolio.positions))
28
+ for position in assets:
29
+ positions.append(
30
+ position.copy(
31
+ weighting=Decimal(1 / len(assets)), date=self.trade_date, asset_valuation_date=self.trade_date
32
+ )
33
+ )
34
+ return Portfolio(positions)
@@ -12,7 +12,9 @@ from wbfdm.models import (
12
12
  InstrumentListThroughModel,
13
13
  )
14
14
 
15
+ from wbportfolio.pms.analytics.utils import fix_quantization_error
15
16
  from wbportfolio.pms.typing import Portfolio, Position
17
+ from wbportfolio.pms.typing import Portfolio as PortfolioDTO
16
18
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
17
19
  from wbportfolio.rebalancing.decorators import register
18
20
 
@@ -20,9 +22,17 @@ from wbportfolio.rebalancing.decorators import register
20
22
  @register("Market Capitalization Rebalancing")
21
23
  class MarketCapitalizationRebalancing(AbstractRebalancingModel):
22
24
  TARGET_CURRENCY: str = "USD"
25
+ MIN_WEIGHT: float = 1e-5 # we allow only weight of minimum 0.01%
23
26
 
24
- def __init__(self, *args, bypass_exchange_check: bool = False, ffill_market_cap_limit: int = 5, **kwargs):
25
- super().__init__(*args, **kwargs)
27
+ def __init__(
28
+ self,
29
+ *args,
30
+ bypass_exchange_check: bool = False,
31
+ ffill_market_cap_limit: int = 5,
32
+ effective_portfolio: PortfolioDTO | None = None,
33
+ **kwargs,
34
+ ):
35
+ super().__init__(*args, effective_portfolio=effective_portfolio, **kwargs)
26
36
  self.bypass_exchange_check = bypass_exchange_check
27
37
  instruments = self._get_instruments(**kwargs)
28
38
  self.market_cap_df = pd.DataFrame(
@@ -78,9 +88,7 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
78
88
  )
79
89
 
80
90
  if not instrument_ids:
81
- instrument_ids = list(
82
- self.portfolio.assets.filter(date=self.trade_date).values_list("underlying_instrument", flat=True)
83
- )
91
+ instrument_ids = list(self.effective_portfolio.positions_map.keys())
84
92
 
85
93
  return (
86
94
  Instrument.objects.filter(id__in=instrument_ids)
@@ -100,22 +108,21 @@ class MarketCapitalizationRebalancing(AbstractRebalancingModel):
100
108
  return df.any()
101
109
  else:
102
110
  if missing_exchanges.exists():
103
- setattr(
104
- self,
105
- "_validation_errors",
106
- f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}",
107
- )
111
+ self._validation_errors = f"Couldn't find any market capitalization for exchanges {', '.join([str(e) for e in missing_exchanges])}"
108
112
  return df.all()
109
113
  return False
110
114
 
111
115
  def get_target_portfolio(self) -> Portfolio:
112
116
  positions = []
113
- total_market_cap = self.market_cap_df.dropna().sum()
114
- for underlying_instrument, market_cap in self.market_cap_df.to_dict().items():
115
- if np.isnan(market_cap):
117
+ df = self.market_cap_df / self.market_cap_df.dropna().sum()
118
+ df = df[df > self.MIN_WEIGHT]
119
+ df = df / df.sum()
120
+ df = fix_quantization_error(df, 8)
121
+ for underlying_instrument, weighting in df.to_dict().items():
122
+ if np.isnan(weighting):
116
123
  weighting = Decimal(0)
117
124
  else:
118
- weighting = Decimal(market_cap / total_market_cap)
125
+ weighting = round(Decimal(weighting), 8)
119
126
  positions.append(
120
127
  Position(
121
128
  underlying_instrument=underlying_instrument,
@@ -1,5 +1,3 @@
1
- from wbfdm.models import InstrumentPrice
2
-
3
1
  from wbportfolio.pms.typing import Portfolio
4
2
  from wbportfolio.rebalancing.base import AbstractRebalancingModel
5
3
  from wbportfolio.rebalancing.decorators import register
@@ -9,6 +7,11 @@ from wbportfolio.rebalancing.decorators import register
9
7
  class ModelPortfolioRebalancing(AbstractRebalancingModel):
10
8
  def __init__(self, *args, **kwargs):
11
9
  super().__init__(*args, **kwargs)
10
+ self.value_date = (
11
+ self.trade_date
12
+ if self.model_portfolio.assets.filter(date=self.trade_date).exists()
13
+ else self.last_effective_date
14
+ )
12
15
 
13
16
  @property
14
17
  def model_portfolio_rel(self):
@@ -16,26 +19,19 @@ class ModelPortfolioRebalancing(AbstractRebalancingModel):
16
19
 
17
20
  @property
18
21
  def model_portfolio(self):
19
- if model_portfolio_rel := self.model_portfolio_rel:
20
- return model_portfolio_rel.dependency_portfolio
22
+ return self.model_portfolio_rel.dependency_portfolio if self.model_portfolio_rel else None
23
+
24
+ @property
25
+ def assets(self):
26
+ return self.model_portfolio.get_positions(self.value_date) if self.model_portfolio else []
21
27
 
22
28
  def is_valid(self) -> bool:
23
- if model_portfolio := self.model_portfolio:
24
- assets = model_portfolio.get_positions(self.last_effective_date)
25
- return (
26
- assets.exists()
27
- and InstrumentPrice.objects.filter(
28
- date=self.trade_date, instrument__in=assets.values("underlying_quote")
29
- ).exists()
30
- )
31
- return False
29
+ return len(self.assets) > 0
32
30
 
33
31
  def get_target_portfolio(self) -> Portfolio:
34
32
  positions = []
35
- assets = self.model_portfolio.get_positions(self.last_effective_date)
36
-
37
- for asset in assets:
38
- asset.date = self.trade_date
39
- asset.asset_valuation_date = self.trade_date
33
+ for asset in self.assets:
34
+ asset.date = self.value_date
35
+ asset.asset_valuation_date = self.value_date
40
36
  positions.append(asset._build_dto())
41
37
  return Portfolio(positions=tuple(positions))
@@ -9,3 +9,4 @@ from .ucits_portfolio import *
9
9
  from .accounts import *
10
10
  from .product_integrity import *
11
11
  from .liquidity_risk import *
12
+ from .esg_aggregation_portfolio import *
@@ -2,7 +2,7 @@ from typing import Generator
2
2
 
3
3
  from wbcompliance.models.risk_management import backend
4
4
  from wbcompliance.models.risk_management.dispatch import register
5
- from wbcompliance.models.risk_management.rules import RiskIncidentType
5
+ from wbcompliance.models.risk_management.incidents import RiskIncidentType
6
6
  from wbcore import serializers as wb_serializers
7
7
  from wbfdm.enums import ESGControveryFlag
8
8
  from wbfdm.models import Instrument
@@ -44,7 +44,7 @@ class RuleBackend(ActivePortfolioRelationshipMixin):
44
44
  return RuleBackendSerializer
45
45
 
46
46
  def _process_dto(self, portfolio: PortfolioDTO, **kwargs) -> Generator[backend.IncidentResult, None, None]:
47
- for instrument_id, weight in portfolio.positions_map.items():
47
+ for instrument_id in portfolio.positions_map.keys():
48
48
  instrument = Instrument.objects.get(id=instrument_id)
49
49
  if (
50
50
  controversies := Controversy.objects.filter(