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

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

Potentially problematic release.


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

Files changed (273) hide show
  1. wbportfolio/admin/__init__.py +3 -1
  2. wbportfolio/admin/indexes.py +1 -1
  3. wbportfolio/admin/orders/__init__.py +2 -0
  4. wbportfolio/admin/orders/order_proposals.py +16 -0
  5. wbportfolio/admin/orders/orders.py +32 -0
  6. wbportfolio/admin/portfolio.py +11 -5
  7. wbportfolio/admin/product_groups.py +1 -1
  8. wbportfolio/admin/products.py +2 -1
  9. wbportfolio/admin/{transactions/rebalancing.py → rebalancing.py} +1 -1
  10. wbportfolio/admin/transactions/__init__.py +0 -2
  11. wbportfolio/admin/transactions/dividends.py +40 -4
  12. wbportfolio/admin/transactions/fees.py +24 -14
  13. wbportfolio/admin/transactions/trades.py +34 -27
  14. wbportfolio/analysis/claims.py +5 -6
  15. wbportfolio/api_clients/ubs.py +162 -0
  16. wbportfolio/constants.py +1 -0
  17. wbportfolio/contrib/company_portfolio/configs/display.py +22 -10
  18. wbportfolio/contrib/company_portfolio/configs/previews.py +3 -3
  19. wbportfolio/contrib/company_portfolio/filters.py +10 -10
  20. wbportfolio/contrib/company_portfolio/models.py +69 -39
  21. wbportfolio/contrib/company_portfolio/scripts.py +7 -2
  22. wbportfolio/contrib/company_portfolio/serializers.py +32 -22
  23. wbportfolio/contrib/company_portfolio/tasks.py +12 -1
  24. wbportfolio/contrib/company_portfolio/tests/conftest.py +2 -2
  25. wbportfolio/defaults/fees/default.py +7 -15
  26. wbportfolio/factories/__init__.py +2 -2
  27. wbportfolio/factories/assets.py +1 -1
  28. wbportfolio/factories/dividends.py +8 -3
  29. wbportfolio/factories/fees.py +8 -4
  30. wbportfolio/factories/orders/__init__.py +2 -0
  31. wbportfolio/factories/orders/order_proposals.py +21 -0
  32. wbportfolio/factories/orders/orders.py +34 -0
  33. wbportfolio/factories/portfolios.py +2 -1
  34. wbportfolio/factories/product_groups.py +3 -3
  35. wbportfolio/factories/products.py +3 -3
  36. wbportfolio/factories/rebalancing.py +1 -1
  37. wbportfolio/factories/trades.py +12 -16
  38. wbportfolio/filters/assets.py +18 -4
  39. wbportfolio/filters/orders/__init__.py +2 -0
  40. wbportfolio/filters/orders/order_proposals.py +55 -0
  41. wbportfolio/filters/orders/orders.py +11 -0
  42. wbportfolio/filters/portfolios.py +38 -1
  43. wbportfolio/filters/positions.py +0 -1
  44. wbportfolio/filters/transactions/__init__.py +1 -2
  45. wbportfolio/filters/transactions/fees.py +5 -12
  46. wbportfolio/filters/transactions/trades.py +16 -8
  47. wbportfolio/filters/transactions/utils.py +42 -0
  48. wbportfolio/import_export/backends/ubs/__init__.py +1 -0
  49. wbportfolio/import_export/backends/ubs/asset_position.py +6 -7
  50. wbportfolio/import_export/backends/ubs/fees.py +10 -20
  51. wbportfolio/import_export/backends/ubs/instrument_price.py +6 -6
  52. wbportfolio/import_export/backends/ubs/trade.py +48 -0
  53. wbportfolio/import_export/backends/utils.py +0 -17
  54. wbportfolio/import_export/handlers/asset_position.py +22 -10
  55. wbportfolio/import_export/handlers/dividend.py +8 -8
  56. wbportfolio/import_export/handlers/fees.py +13 -23
  57. wbportfolio/import_export/handlers/orders.py +71 -0
  58. wbportfolio/import_export/handlers/trade.py +53 -77
  59. wbportfolio/import_export/parsers/default_mapping.py +1 -1
  60. wbportfolio/import_export/parsers/jpmorgan/customer_trade.py +2 -2
  61. wbportfolio/import_export/parsers/jpmorgan/fees.py +4 -4
  62. wbportfolio/import_export/parsers/jpmorgan/strategy.py +59 -85
  63. wbportfolio/import_export/parsers/jpmorgan/valuation.py +2 -2
  64. wbportfolio/import_export/parsers/leonteq/customer_trade.py +5 -5
  65. wbportfolio/import_export/parsers/leonteq/fees.py +11 -7
  66. wbportfolio/import_export/parsers/leonteq/trade.py +2 -6
  67. wbportfolio/import_export/parsers/natixis/d1_fees.py +2 -2
  68. wbportfolio/import_export/parsers/natixis/dividend.py +4 -9
  69. wbportfolio/import_export/parsers/natixis/equity.py +22 -4
  70. wbportfolio/import_export/parsers/natixis/fees.py +7 -9
  71. wbportfolio/import_export/parsers/natixis/utils.py +13 -19
  72. wbportfolio/import_export/parsers/sg_lux/customer_trade_pending_slk.py +1 -1
  73. wbportfolio/import_export/parsers/sg_lux/equity.py +10 -10
  74. wbportfolio/import_export/parsers/sg_lux/fees.py +2 -2
  75. wbportfolio/import_export/parsers/sg_lux/perf_fees.py +2 -2
  76. wbportfolio/import_export/parsers/sg_lux/sylk.py +12 -11
  77. wbportfolio/import_export/parsers/sg_lux/utils.py +2 -2
  78. wbportfolio/import_export/parsers/sg_lux/valuation.py +4 -2
  79. wbportfolio/import_export/parsers/societe_generale/strategy.py +5 -5
  80. wbportfolio/import_export/parsers/tellco/customer_trade.py +2 -1
  81. wbportfolio/import_export/parsers/tellco/valuation.py +4 -3
  82. wbportfolio/import_export/parsers/ubs/api/fees.py +2 -2
  83. wbportfolio/import_export/parsers/ubs/api/trade.py +39 -0
  84. wbportfolio/import_export/parsers/ubs/customer_trade.py +7 -5
  85. wbportfolio/import_export/parsers/ubs/equity.py +3 -2
  86. wbportfolio/import_export/parsers/ubs/valuation.py +2 -1
  87. wbportfolio/import_export/parsers/vontobel/customer_trade.py +2 -3
  88. wbportfolio/import_export/parsers/vontobel/historical_customer_trade.py +0 -1
  89. wbportfolio/import_export/parsers/vontobel/management_fees.py +12 -20
  90. wbportfolio/import_export/parsers/vontobel/performance_fees.py +5 -8
  91. wbportfolio/import_export/parsers/vontobel/valuation_api.py +4 -1
  92. wbportfolio/import_export/resources/trades.py +3 -3
  93. wbportfolio/import_export/utils.py +3 -1
  94. wbportfolio/jinja2/wbportfolio/sql/aum_nnm.sql +2 -2
  95. wbportfolio/metric/backends/base.py +2 -2
  96. wbportfolio/migrations/0059_fees_unique_fees.py +1 -1
  97. wbportfolio/migrations/0077_remove_transaction_currency_and_more.py +622 -0
  98. wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
  99. wbportfolio/migrations/0079_alter_trade_drift_factor.py +19 -0
  100. wbportfolio/migrations/0080_alter_trade_drift_factor_alter_trade_weighting.py +19 -0
  101. wbportfolio/migrations/0081_alter_trade_drift_factor.py +19 -0
  102. wbportfolio/migrations/0082_remove_tradeproposal_creator_and_more.py +93 -0
  103. wbportfolio/migrations/0083_order_alter_trade_options_and_more.py +181 -0
  104. wbportfolio/migrations/0084_orderproposal_min_order_value.py +25 -0
  105. wbportfolio/migrations/0085_order_desired_target_weight.py +26 -0
  106. wbportfolio/migrations/0086_orderproposal_total_cash_weight.py +19 -0
  107. wbportfolio/migrations/0087_product_order_routing_custodian_adapter.py +94 -0
  108. wbportfolio/migrations/0088_orderproposal_total_effective_portfolio_contribution.py +19 -0
  109. wbportfolio/migrations/0089_orderproposal_min_weighting.py +71 -0
  110. wbportfolio/migrations/0090_dividendtransaction_price_fx_portfolio_and_more.py +44 -0
  111. wbportfolio/migrations/0091_remove_order_execution_confirmed_and_more.py +32 -0
  112. wbportfolio/migrations/0092_order_quantization_error_alter_orderproposal_status.py +49 -0
  113. wbportfolio/migrations/0093_remove_portfolioportfoliothroughmodel_unique_primary_and_more.py +35 -0
  114. wbportfolio/models/__init__.py +2 -0
  115. wbportfolio/models/adjustments.py +1 -1
  116. wbportfolio/models/asset.py +28 -170
  117. wbportfolio/models/builder.py +323 -0
  118. wbportfolio/models/custodians.py +3 -3
  119. wbportfolio/models/exceptions.py +1 -1
  120. wbportfolio/models/graphs/portfolio.py +1 -1
  121. wbportfolio/models/graphs/utils.py +11 -11
  122. wbportfolio/models/mixins/instruments.py +7 -0
  123. wbportfolio/models/mixins/liquidity_stress_test.py +4 -4
  124. wbportfolio/models/orders/__init__.py +2 -0
  125. wbportfolio/models/orders/order_proposals.py +1414 -0
  126. wbportfolio/models/orders/orders.py +410 -0
  127. wbportfolio/models/portfolio.py +311 -289
  128. wbportfolio/models/portfolio_relationship.py +6 -0
  129. wbportfolio/models/products.py +12 -0
  130. wbportfolio/models/{transactions/rebalancing.py → rebalancing.py} +40 -27
  131. wbportfolio/models/roles.py +4 -10
  132. wbportfolio/models/transactions/__init__.py +0 -4
  133. wbportfolio/models/transactions/claim.py +7 -6
  134. wbportfolio/models/transactions/dividends.py +42 -5
  135. wbportfolio/models/transactions/fees.py +55 -22
  136. wbportfolio/models/transactions/trades.py +121 -442
  137. wbportfolio/models/transactions/transactions.py +78 -158
  138. wbportfolio/models/utils.py +100 -1
  139. wbportfolio/order_routing/__init__.py +35 -0
  140. wbportfolio/order_routing/adapters/__init__.py +65 -0
  141. wbportfolio/order_routing/adapters/ubs.py +195 -0
  142. wbportfolio/order_routing/router.py +33 -0
  143. wbportfolio/order_routing/tests/__init__.py +0 -0
  144. wbportfolio/order_routing/tests/test_router.py +110 -0
  145. wbportfolio/permissions.py +7 -0
  146. wbportfolio/pms/analytics/portfolio.py +17 -9
  147. wbportfolio/pms/analytics/utils.py +9 -0
  148. wbportfolio/pms/trading/__init__.py +0 -1
  149. wbportfolio/pms/trading/optimizer.py +61 -0
  150. wbportfolio/pms/typing.py +198 -63
  151. wbportfolio/rebalancing/base.py +12 -1
  152. wbportfolio/rebalancing/decorators.py +1 -1
  153. wbportfolio/rebalancing/models/composite.py +4 -8
  154. wbportfolio/rebalancing/models/equally_weighted.py +13 -11
  155. wbportfolio/rebalancing/models/market_capitalization_weighted.py +21 -14
  156. wbportfolio/rebalancing/models/model_portfolio.py +14 -18
  157. wbportfolio/risk_management/backends/__init__.py +1 -0
  158. wbportfolio/risk_management/backends/controversy_portfolio.py +2 -2
  159. wbportfolio/risk_management/backends/esg_aggregation_portfolio.py +64 -0
  160. wbportfolio/risk_management/backends/exposure_portfolio.py +4 -4
  161. wbportfolio/risk_management/backends/instrument_list_portfolio.py +3 -3
  162. wbportfolio/risk_management/tests/test_esg_aggregation_portfolio.py +49 -0
  163. wbportfolio/risk_management/tests/test_exposure_portfolio.py +1 -1
  164. wbportfolio/risk_management/tests/test_stop_loss_instrument.py +2 -2
  165. wbportfolio/risk_management/tests/test_stop_loss_portfolio.py +1 -1
  166. wbportfolio/serializers/__init__.py +1 -0
  167. wbportfolio/serializers/orders/__init__.py +2 -0
  168. wbportfolio/serializers/orders/order_proposals.py +115 -0
  169. wbportfolio/serializers/orders/orders.py +283 -0
  170. wbportfolio/serializers/portfolios.py +7 -7
  171. wbportfolio/serializers/positions.py +2 -2
  172. wbportfolio/serializers/rebalancing.py +1 -1
  173. wbportfolio/serializers/signals.py +9 -12
  174. wbportfolio/serializers/transactions/__init__.py +1 -10
  175. wbportfolio/serializers/transactions/claim.py +2 -2
  176. wbportfolio/serializers/transactions/dividends.py +37 -9
  177. wbportfolio/serializers/transactions/fees.py +39 -10
  178. wbportfolio/serializers/transactions/trades.py +55 -157
  179. wbportfolio/tasks.py +43 -5
  180. wbportfolio/tests/analysis/__init__.py +0 -0
  181. wbportfolio/tests/analysis/test_claims.py +85 -0
  182. wbportfolio/tests/conftest.py +12 -12
  183. wbportfolio/tests/models/orders/__init__.py +0 -0
  184. wbportfolio/tests/models/orders/test_order_proposals.py +1046 -0
  185. wbportfolio/tests/models/test_assets.py +7 -3
  186. wbportfolio/tests/models/test_imports.py +9 -13
  187. wbportfolio/tests/models/test_portfolios.py +102 -95
  188. wbportfolio/tests/models/test_products.py +11 -0
  189. wbportfolio/tests/models/test_splits.py +1 -6
  190. wbportfolio/tests/models/test_utils.py +140 -0
  191. wbportfolio/tests/models/transactions/test_fees.py +7 -13
  192. wbportfolio/tests/models/transactions/test_rebalancing.py +5 -5
  193. wbportfolio/tests/models/transactions/test_trades.py +0 -20
  194. wbportfolio/tests/pms/test_analytics.py +22 -3
  195. wbportfolio/tests/rebalancing/test_models.py +51 -57
  196. wbportfolio/tests/signals.py +10 -20
  197. wbportfolio/tests/tests.py +3 -1
  198. wbportfolio/tests/viewsets/test_products.py +1 -0
  199. wbportfolio/urls.py +10 -13
  200. wbportfolio/viewsets/__init__.py +9 -4
  201. wbportfolio/viewsets/assets.py +3 -204
  202. wbportfolio/viewsets/charts/__init__.py +6 -1
  203. wbportfolio/viewsets/charts/assets.py +344 -154
  204. wbportfolio/viewsets/configs/buttons/__init__.py +2 -2
  205. wbportfolio/viewsets/configs/buttons/assets.py +1 -1
  206. wbportfolio/viewsets/configs/buttons/mixins.py +4 -4
  207. wbportfolio/viewsets/configs/buttons/portfolios.py +45 -1
  208. wbportfolio/viewsets/configs/buttons/products.py +32 -2
  209. wbportfolio/viewsets/configs/display/__init__.py +2 -5
  210. wbportfolio/viewsets/configs/display/assets.py +6 -19
  211. wbportfolio/viewsets/configs/display/fees.py +3 -3
  212. wbportfolio/viewsets/configs/display/portfolios.py +5 -5
  213. wbportfolio/viewsets/configs/display/products.py +1 -1
  214. wbportfolio/viewsets/configs/display/rebalancing.py +2 -2
  215. wbportfolio/viewsets/configs/display/reconciliations.py +4 -4
  216. wbportfolio/viewsets/configs/display/trades.py +1 -189
  217. wbportfolio/viewsets/configs/endpoints/__init__.py +3 -7
  218. wbportfolio/viewsets/configs/endpoints/fees.py +2 -2
  219. wbportfolio/viewsets/configs/endpoints/trades.py +0 -41
  220. wbportfolio/viewsets/configs/menu/__init__.py +1 -1
  221. wbportfolio/viewsets/configs/menu/orders.py +11 -0
  222. wbportfolio/viewsets/configs/titles/__init__.py +2 -3
  223. wbportfolio/viewsets/configs/titles/fees.py +4 -8
  224. wbportfolio/viewsets/esg.py +3 -5
  225. wbportfolio/viewsets/mixins.py +5 -1
  226. wbportfolio/viewsets/orders/__init__.py +6 -0
  227. wbportfolio/viewsets/orders/configs/__init__.py +4 -0
  228. wbportfolio/viewsets/orders/configs/buttons/__init__.py +2 -0
  229. wbportfolio/viewsets/orders/configs/buttons/order_proposals.py +188 -0
  230. wbportfolio/viewsets/orders/configs/buttons/orders.py +113 -0
  231. wbportfolio/viewsets/orders/configs/displays/__init__.py +2 -0
  232. wbportfolio/viewsets/orders/configs/displays/order_proposals.py +157 -0
  233. wbportfolio/viewsets/orders/configs/displays/orders.py +232 -0
  234. wbportfolio/viewsets/orders/configs/endpoints/__init__.py +2 -0
  235. wbportfolio/viewsets/orders/configs/endpoints/order_proposals.py +21 -0
  236. wbportfolio/viewsets/orders/configs/endpoints/orders.py +28 -0
  237. wbportfolio/viewsets/orders/configs/titles/__init__.py +0 -0
  238. wbportfolio/viewsets/orders/configs/titles/orders.py +0 -0
  239. wbportfolio/viewsets/orders/order_proposals.py +252 -0
  240. wbportfolio/viewsets/orders/orders.py +277 -0
  241. wbportfolio/viewsets/portfolios.py +36 -12
  242. wbportfolio/viewsets/positions.py +3 -2
  243. wbportfolio/viewsets/products.py +6 -6
  244. wbportfolio/viewsets/{transactions/rebalancing.py → rebalancing.py} +2 -2
  245. wbportfolio/viewsets/transactions/__init__.py +3 -14
  246. wbportfolio/viewsets/transactions/fees.py +22 -22
  247. wbportfolio/viewsets/transactions/trades.py +1 -180
  248. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/METADATA +3 -1
  249. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/RECORD +252 -203
  250. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/WHEEL +1 -1
  251. wbportfolio/admin/transactions/transactions.py +0 -38
  252. wbportfolio/factories/transactions.py +0 -22
  253. wbportfolio/fdm/tasks.py +0 -13
  254. wbportfolio/filters/transactions/transactions.py +0 -99
  255. wbportfolio/models/transactions/expiry.py +0 -7
  256. wbportfolio/models/transactions/trade_proposals.py +0 -704
  257. wbportfolio/pms/trading/handler.py +0 -161
  258. wbportfolio/serializers/transactions/expiry.py +0 -18
  259. wbportfolio/serializers/transactions/trade_proposals.py +0 -76
  260. wbportfolio/serializers/transactions/transactions.py +0 -85
  261. wbportfolio/tests/models/transactions/test_trade_proposals.py +0 -410
  262. wbportfolio/viewsets/configs/buttons/trade_proposals.py +0 -66
  263. wbportfolio/viewsets/configs/display/trade_proposals.py +0 -100
  264. wbportfolio/viewsets/configs/display/transactions.py +0 -55
  265. wbportfolio/viewsets/configs/endpoints/trade_proposals.py +0 -18
  266. wbportfolio/viewsets/configs/endpoints/transactions.py +0 -14
  267. wbportfolio/viewsets/configs/menu/transactions.py +0 -9
  268. wbportfolio/viewsets/configs/titles/transactions.py +0 -9
  269. wbportfolio/viewsets/signals.py +0 -43
  270. wbportfolio/viewsets/transactions/trade_proposals.py +0 -139
  271. wbportfolio/viewsets/transactions/transactions.py +0 -122
  272. /wbportfolio/{fdm → api_clients}/__init__.py +0 -0
  273. {wbportfolio-1.52.0.dist-info → wbportfolio-1.59.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,195 @@
1
+ import logging
2
+ from datetime import datetime
3
+
4
+ from django.conf import settings
5
+ from requests import HTTPError
6
+
7
+ from wbportfolio.api_clients.ubs import UBSNeoAPIClient
8
+ from wbportfolio.pms.typing import Order
9
+
10
+ from .. import ExecutionInstruction, ExecutionStatus, RoutingException
11
+ from . import BaseCustodianAdapter
12
+
13
+ logger = logging.getLogger("oms")
14
+
15
+
16
+ ASSET_CLASS_MAP = {
17
+ Order.AssetType.EQUITY: "EQUITY",
18
+ Order.AssetType.AMERICAN_DEPOSITORY_RECEIPT: "EQUITY",
19
+ } # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
20
+ ASSET_CLASS_MAP_INV = {
21
+ v: k for k, v in ASSET_CLASS_MAP.items()
22
+ } # API can support BOND, FUTURE, OPTION, and DYNAMIC_STRATEGY
23
+
24
+ STATUS_MAP = {
25
+ "Amend Pending": ExecutionStatus.PENDING,
26
+ "Cancel Pending": ExecutionStatus.PENDING,
27
+ "Cancelled": ExecutionStatus.CANCELLED,
28
+ "Complete": ExecutionStatus.COMPLETED,
29
+ "Complete (Order Cancelled)": ExecutionStatus.COMPLETED,
30
+ "Complete (Partial Fill)": ExecutionStatus.COMPLETED,
31
+ "In Draft": ExecutionStatus.IN_DRAFT,
32
+ "Pending Approval": ExecutionStatus.PENDING,
33
+ "Pending Execution": ExecutionStatus.PENDING,
34
+ "Rebalance Cancelled": ExecutionStatus.CANCELLED,
35
+ "Rebalance Cancelled (Executing partially)": ExecutionStatus.CANCELLED,
36
+ "Rejected": ExecutionStatus.REJECTED,
37
+ "Rejection Acknowledged": ExecutionStatus.PENDING,
38
+ "Waiting for Response": ExecutionStatus.PENDING,
39
+ }
40
+ EXECUTION_INSTRUCTION_MAP = {
41
+ ExecutionInstruction.MARKET_ON_CLOSE: "MARKET_ON_CLOSE",
42
+ ExecutionInstruction.GUARANTEED_MARKET_ON_CLOSE: "GUARANTEED_MARKET_ON_CLOSE",
43
+ ExecutionInstruction.GUARANTEED_MARKET_ON_OPEN: "GUARANTEED_MARKET_ON_OPEN",
44
+ ExecutionInstruction.GPW_MARKET_ON_CLOSE: "GPW_MARKET_ON_CLOSE",
45
+ ExecutionInstruction.MARKET_ON_OPEN: "MARKET_ON_OPEN",
46
+ ExecutionInstruction.IN_LINE_WITH_VOLUME: "IN_LINE_WITH_VOLUME",
47
+ ExecutionInstruction.LIMIT_ORDER: "LIMIT_ORDER",
48
+ ExecutionInstruction.VWAP: "VWAP",
49
+ ExecutionInstruction.TWAP: "TWAP",
50
+ }
51
+ EXECUTION_INSTRUCTION_MAP_INV = {v: k for k, v in EXECUTION_INSTRUCTION_MAP.items()}
52
+
53
+
54
+ class CustodianAdapter(BaseCustodianAdapter):
55
+ client: UBSNeoAPIClient
56
+
57
+ def __init__(self, *args, raise_exception: bool = False, **kwargs):
58
+ super().__init__(*args, **kwargs)
59
+ self.raise_exception = raise_exception
60
+
61
+ def _handle_response(self, res):
62
+ logger.info(res["message"])
63
+ if errors := res.get("errors"):
64
+ logger.warning(errors)
65
+ if self.raise_exception:
66
+ raise RoutingException(errors)
67
+
68
+ def _serialize_execution_instruction(
69
+ self, execution_instruction: ExecutionInstruction, execution_parameters: dict
70
+ ):
71
+ repr = EXECUTION_INSTRUCTION_MAP[execution_instruction]
72
+ if execution_parameters:
73
+ if execution_instruction == ExecutionInstruction.IN_LINE_WITH_VOLUME:
74
+ repr += f':{execution_parameters["percent"]:.0f%}'
75
+ elif execution_instruction == ExecutionInstruction.LIMIT_ORDER:
76
+ repr += f':{execution_parameters["price"]:.1f}'
77
+ if good_for_date := execution_parameters.get("good_for_date"):
78
+ repr += f",{good_for_date}"
79
+ elif (
80
+ execution_instruction == ExecutionInstruction.VWAP
81
+ or execution_instruction == ExecutionInstruction.TWAP
82
+ ):
83
+ repr += f':{execution_parameters["period"]},{execution_parameters["time"]}'
84
+ return repr
85
+
86
+ def serialize_orders(self, orders: list[Order]) -> list[dict[str, str]]:
87
+ items = []
88
+ for order in orders:
89
+ if order.refinitiv_identifier_code:
90
+ identifier_type, identifier = "RIC", order.refinitiv_identifier_code
91
+ elif order.bloomberg_ticker:
92
+ identifier_type, identifier = "BBTICKER", order.bloomberg_ticker
93
+ else:
94
+ identifier_type, identifier = "SEDOL", order.sedol
95
+ item = {
96
+ "assetClass": ASSET_CLASS_MAP[order.asset_class],
97
+ "identifierType": identifier_type,
98
+ "identifier": identifier,
99
+ "executionInstruction": self._serialize_execution_instruction(
100
+ order.execution_instruction, order.execution_instruction_parameters
101
+ ),
102
+ "userElementId": str(order.id),
103
+ "tradeDate": order.trade_date.strftime("%Y-%m-%d"),
104
+ }
105
+ if order.shares:
106
+ item["sharesToTrade"] = str(order.shares)
107
+ else:
108
+ item["targetWeight"] = str(order.target_weight * 100)
109
+ items.append(item)
110
+ return items
111
+
112
+ def deserialize_items(self, items: list[dict[str, str]]):
113
+ orders = []
114
+ for item in items:
115
+ orders.append(
116
+ Order(
117
+ id=item.get("userElementId"),
118
+ asset_class=ASSET_CLASS_MAP_INV[item.get("assetClass")],
119
+ refinitiv_identifier_code=item.get(
120
+ "ric", item["identifier"] if item.get("identifierType") == "RIC" else None
121
+ ),
122
+ bloomberg_ticker=item["identifier"] if item.get("identifierType") == "BBTICKER" else None,
123
+ sedol=item["identifier"] if item.get("identifierType") == "SEDOL" else None,
124
+ trade_date=datetime.strptime(item.get("tradeDate"), "%Y-%m-%d"),
125
+ target_weight=float(item["targetWeight"]) / 100 if "targetWeight" in item else None,
126
+ shares=float(item["sharesToTrade"]) if "sharesToTrade" in item else None,
127
+ execution_instruction=EXECUTION_INSTRUCTION_MAP_INV[item["executionInstruction"]],
128
+ )
129
+ )
130
+ return orders
131
+
132
+ def authenticate(self) -> bool:
133
+ """
134
+ Authenticate or renew tokens with the custodian API.
135
+ Raises an exception if authentication fails.
136
+ """
137
+ self.client = UBSNeoAPIClient(settings.UBS_NEO_API_TOKEN)
138
+ return True
139
+
140
+ def is_valid(self) -> bool:
141
+ """
142
+ Check whether the given isin is valid and can be rebalanced
143
+ """
144
+
145
+ try:
146
+ status_res = self.client.get_rebalance_service_status()
147
+
148
+ isin_res = self.client.get_rebalance_status_for_isin(self.isin)
149
+ self._handle_response(status_res)
150
+ self._handle_response(isin_res)
151
+ return (
152
+ status_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
153
+ and isin_res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
154
+ )
155
+ except (HTTPError, KeyError) as e:
156
+ logger.warning(f"Couldn't validate adapter: {str(e)}")
157
+ return False
158
+
159
+ def submit_rebalancing(
160
+ self, items: list[dict[str, str]], as_draft: bool = True
161
+ ) -> tuple[list[dict[str, str]], str]:
162
+ """
163
+ Submit a rebalance order for the certificate.
164
+ """
165
+ if not as_draft:
166
+ res = self.client.submit_rebalance(self.isin, items)
167
+ else:
168
+ res = self.client.save_draft(self.isin, items)
169
+ self._handle_response(res)
170
+ return res["rebalanceItems"], res["message"]
171
+
172
+ def cancel_current_rebalancing(self) -> bool:
173
+ """
174
+ Cancel an existing rebalance order identified by ISIN.
175
+ """
176
+ try:
177
+ res = self.client.cancel_rebalance(self.isin)
178
+ self._handle_response(res)
179
+ return res["status"] == UBSNeoAPIClient.SUCCESS_VALUE
180
+ except (HTTPError, KeyError):
181
+ return False
182
+
183
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
184
+ res = self.client.get_rebalance_status_for_isin(self.isin)
185
+ self._handle_response(res)
186
+ status = res.get("rebalanceStatus", "")
187
+ return STATUS_MAP.get(status, ExecutionStatus.UNKNOWN), status
188
+
189
+ def get_current_rebalancing(self) -> list[dict[str, str]]:
190
+ """
191
+ Fetch the current rebalance request details for a certificate.
192
+ """
193
+ res = self.client.get_current_rebalance_request(self.isin)
194
+ self._handle_response(res)
195
+ return res["rebalanceItems"]
@@ -0,0 +1,33 @@
1
+ from django.conf import settings
2
+
3
+ from wbportfolio.order_routing import ExecutionStatus
4
+ from wbportfolio.order_routing.adapters import BaseCustodianAdapter
5
+ from wbportfolio.pms.typing import Order
6
+
7
+
8
+ class Router:
9
+ def __init__(self, adapter: BaseCustodianAdapter):
10
+ self.adapter = adapter
11
+
12
+ @property
13
+ def submit_as_draft(self):
14
+ return getattr(settings, "DEBUG", True) or getattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
15
+
16
+ def submit_rebalancing(self, orders: list[Order]) -> tuple[list[Order], tuple[str, str]]:
17
+ """
18
+ Submit a rebalance order for the certificate.
19
+ """
20
+ items = self.adapter.serialize_orders(orders)
21
+ confirmed_items, msg = self.adapter.submit_rebalancing(items, as_draft=self.submit_as_draft)
22
+ status = ExecutionStatus.IN_DRAFT if self.submit_as_draft else ExecutionStatus.PENDING
23
+ return self.adapter.deserialize_items(confirmed_items), (status, msg)
24
+
25
+ def get_rebalance_status(self) -> tuple[ExecutionStatus, str]:
26
+ return self.adapter.get_rebalance_status()
27
+
28
+ def cancel_rebalancing(self) -> bool:
29
+ return self.adapter.cancel_current_rebalancing()
30
+
31
+ def get_current_rebalancing_request(self) -> list[Order]:
32
+ items = self.adapter.get_current_rebalancing()
33
+ return self.adapter.deserialize_items(items)
File without changes
@@ -0,0 +1,110 @@
1
+ from unittest.mock import MagicMock, PropertyMock, patch
2
+
3
+ import pytest
4
+ from django.conf import settings
5
+
6
+ from wbportfolio.order_routing import ExecutionStatus
7
+ from wbportfolio.order_routing.router import Router
8
+ from wbportfolio.pms.typing import Order
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_adapter():
13
+ adapter = MagicMock()
14
+ return adapter
15
+
16
+
17
+ @pytest.fixture
18
+ def router(mock_adapter):
19
+ return Router(adapter=mock_adapter)
20
+
21
+
22
+ def test_submit_as_draft_from_settings(monkeypatch, router):
23
+ # Test default True if settings attribute missing
24
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
25
+ monkeypatch.setattr(settings, "DEBUG", False)
26
+ assert router.submit_as_draft is True
27
+
28
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
29
+ monkeypatch.setattr(settings, "DEBUG", True)
30
+ assert router.submit_as_draft is True
31
+
32
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", True)
33
+ monkeypatch.setattr(settings, "DEBUG", True)
34
+ assert router.submit_as_draft is True
35
+
36
+ monkeypatch.setattr(settings, "ORDER_ROUTING_AS_DRAFT", False)
37
+ monkeypatch.setattr(settings, "DEBUG", False)
38
+ assert router.submit_as_draft is False
39
+
40
+
41
+ @patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
42
+ def test_submit_rebalancing_calls_adapter_as_draft(mock_property, router, mock_adapter):
43
+ mock_property.return_value = True
44
+ orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
45
+ serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
46
+ confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
47
+ msg = "Success message"
48
+
49
+ mock_adapter.serialize_orders.return_value = serialized_orders
50
+ mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
51
+ mock_adapter.deserialize_items.return_value = orders
52
+
53
+ result_orders, (status, message) = router.submit_rebalancing(orders)
54
+ assert result_orders == orders
55
+ assert status == ExecutionStatus.IN_DRAFT
56
+ assert message == msg
57
+ mock_adapter.serialize_orders.assert_called_once_with(orders)
58
+ mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=True)
59
+ mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
60
+
61
+
62
+ @patch.object(Router, "submit_as_draft", new_callable=PropertyMock)
63
+ def test_submit_rebalancing_calls_adapter(mock_property, router, mock_adapter):
64
+ mock_property.return_value = False
65
+ orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
66
+ serialized_orders = ["serialized_order1", "serialized_order2"] # simplified serialized orders as items
67
+ confirmed_items = ["confirmed_order1", "confirmed_order2"] # simplified deserialized orders from items
68
+ msg = "Success message"
69
+
70
+ mock_adapter.serialize_orders.return_value = serialized_orders
71
+ mock_adapter.submit_rebalancing.return_value = (confirmed_items, msg)
72
+ mock_adapter.deserialize_items.return_value = orders
73
+
74
+ result_orders, (status, message) = router.submit_rebalancing(orders)
75
+ assert result_orders == orders
76
+ assert status == ExecutionStatus.PENDING
77
+ assert message == msg
78
+ mock_adapter.serialize_orders.assert_called_once_with(orders)
79
+ mock_adapter.submit_rebalancing.assert_called_once_with(serialized_orders, as_draft=False)
80
+ mock_adapter.deserialize_items.assert_called_once_with(confirmed_items)
81
+
82
+
83
+ def test_get_rebalance_status_returns_adapter_status(router, mock_adapter):
84
+ expected_status = ExecutionStatus.PENDING
85
+ expected_msg = "Status message"
86
+ mock_adapter.get_rebalance_status.return_value = (expected_status, expected_msg)
87
+
88
+ status, msg = router.get_rebalance_status()
89
+ assert status == expected_status
90
+ assert msg == expected_msg
91
+ mock_adapter.get_rebalance_status.assert_called_once()
92
+
93
+
94
+ def test_cancel_rebalancing_returns_adapter_result(router, mock_adapter):
95
+ mock_adapter.cancel_current_rebalancing.return_value = True
96
+ result = router.cancel_rebalancing()
97
+ assert result is True
98
+ mock_adapter.cancel_current_rebalancing.assert_called_once()
99
+
100
+
101
+ def test_get_current_rebalancing_request_returns_deserialized(router, mock_adapter):
102
+ serialized_orders = ["order1", "order2"]
103
+ deserialized_orders = [MagicMock(spec=Order), MagicMock(spec=Order)]
104
+ mock_adapter.get_current_rebalancing.return_value = serialized_orders
105
+ mock_adapter.deserialize_items.return_value = deserialized_orders
106
+
107
+ result = router.get_current_rebalancing_request()
108
+ assert result == deserialized_orders
109
+ mock_adapter.get_current_rebalancing.assert_called_once()
110
+ mock_adapter.deserialize_items.assert_called_once_with(serialized_orders)
@@ -1,3 +1,5 @@
1
+ from rest_framework.permissions import IsAuthenticated
2
+
1
3
  from wbportfolio.models import PortfolioRole
2
4
 
3
5
 
@@ -11,3 +13,8 @@ def is_portfolio_manager(request):
11
13
 
12
14
  def is_analyst(request):
13
15
  return PortfolioRole.is_analyst(request.user.profile)
16
+
17
+
18
+ class IsPortfolioManager(IsAuthenticated):
19
+ def has_permission(self, request, view):
20
+ return is_portfolio_manager(request)
@@ -2,6 +2,8 @@ import numpy as np
2
2
  import pandas as pd
3
3
  from skfolio import Portfolio as BasePortfolio
4
4
 
5
+ from .utils import fix_quantization_error
6
+
5
7
 
6
8
  class Portfolio(BasePortfolio):
7
9
  @property
@@ -16,21 +18,27 @@ class Portfolio(BasePortfolio):
16
18
  )
17
19
  return df
18
20
 
19
- def get_next_weights(self) -> dict[int, float]:
21
+ def get_contributions(self) -> tuple[pd.Series, float]:
22
+ returns = self.X.iloc[-1, :].T
23
+ weights = self.all_weights_per_observation.iloc[-1, :].T
24
+ portfolio_returns = (weights * (returns + 1.0)).sum()
25
+ return returns, portfolio_returns
26
+
27
+ def get_next_weights(self, round_precision: int = 8) -> dict[int, float]:
20
28
  """
21
29
  Given the next returns, compute the drifted weights of this portfolio
22
-
30
+ round_precision: Round the weight to the given round number and ensure the total weight reflects this. Default to 8 decimals
23
31
  Returns:
24
32
  A dictionary of weights (instrument ids as keys and weights as values)
25
33
  """
26
- returns = self.X.iloc[-1, :].T
34
+ returns, portfolio_returns = self.get_contributions()
27
35
  weights = self.all_weights_per_observation.iloc[-1, :].T
28
- if weights.sum() != 0:
29
- weights /= weights.sum()
30
- contribution = weights * (returns + 1.0)
31
- if contribution.sum() != 0:
32
- contribution /= contribution.sum()
33
- return contribution.dropna().to_dict()
36
+ next_weights = weights * (returns + 1.0) / portfolio_returns
37
+ next_weights = next_weights.dropna()
38
+ next_weights = next_weights / next_weights.sum()
39
+ if round_precision and not next_weights.empty:
40
+ next_weights = fix_quantization_error(next_weights, round_precision)
41
+ return {i: round(w, round_precision) for i, w in next_weights.items()} # handle float precision manually
34
42
 
35
43
  def get_estimate_net_value(self, previous_net_asset_value: float) -> float:
36
44
  expected_returns = self.weights @ self.X.iloc[-1, :].T
@@ -0,0 +1,9 @@
1
+ import pandas as pd
2
+
3
+
4
+ def fix_quantization_error(df: pd.Series, round_precision: int):
5
+ df = df.round(round_precision)
6
+ quantization_error = 1.0 - df.sum()
7
+ largest_weight = df.idxmax()
8
+ df.loc[largest_weight] = df.loc[largest_weight] + quantization_error
9
+ return df
@@ -1 +0,0 @@
1
- from .handler import TradingService
@@ -0,0 +1,61 @@
1
+ import math
2
+
3
+ import cvxpy as cp
4
+ import numpy as np
5
+
6
+ from wbportfolio.pms.typing import TradeBatch
7
+
8
+
9
+ class TradeShareOptimizer:
10
+ def __init__(self, batch: TradeBatch, portfolio_total_value: float):
11
+ self.batch = batch
12
+ self.portfolio_total_value = portfolio_total_value
13
+
14
+ def optimize(self, target_cash: float = 0.99):
15
+ try:
16
+ return self.optimize_trade_share(target_cash)
17
+ except ValueError:
18
+ return self.floor_trade_share()
19
+
20
+ def optimize_trade_share(self, target_cash: float = 0.01):
21
+ prices_fx_portfolio = np.array([trade.price_fx_portfolio for trade in self.batch.trades])
22
+ target_allocs = np.array([trade.target_weight for trade in self.batch.trades])
23
+
24
+ # Decision variable: number of shares (integers)
25
+ shares = cp.Variable(len(prices_fx_portfolio), integer=True)
26
+
27
+ # Calculate portfolio values
28
+ portfolio_values = cp.multiply(shares, prices_fx_portfolio)
29
+
30
+ # Target values based on allocations
31
+ target_values = self.portfolio_total_value * target_allocs
32
+
33
+ # Objective: minimize absolute deviation from target values
34
+ objective = cp.Minimize(cp.sum(cp.abs(portfolio_values - target_values)))
35
+
36
+ # Constraints
37
+ constraints = [
38
+ shares >= 0, # No short selling
39
+ cp.sum(portfolio_values) <= self.portfolio_total_value, # Don't exceed budget
40
+ cp.sum(portfolio_values) >= (1.0 - target_cash) * self.portfolio_total_value, # Use at least 99% of budget
41
+ ]
42
+
43
+ # Solve
44
+ problem = cp.Problem(objective, constraints)
45
+ problem.solve(solver=cp.CBC)
46
+
47
+ if problem != "optimal":
48
+ raise ValueError(f"Optimization failed: {problem.status}")
49
+
50
+ shares_result = shares.value.astype(int)
51
+ return TradeBatch(
52
+ [
53
+ trade.normalize_target(target_shares=shares_result[index])
54
+ for index, trade in enumerate(self.batch.trades)
55
+ ]
56
+ )
57
+
58
+ def floor_trade_share(self):
59
+ return TradeBatch(
60
+ [trade.normalize_target(target_shares=math.floor(trade.target_shares)) for trade in self.batch.trades]
61
+ )