saxo-api-client 1.0.0__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.
Files changed (196) hide show
  1. saxo_api_client/__init__.py +22 -0
  2. saxo_api_client/auth/__init__.py +35 -0
  3. saxo_api_client/auth/client.py +339 -0
  4. saxo_api_client/auth/models.py +223 -0
  5. saxo_api_client/auth/redirect_server.py +82 -0
  6. saxo_api_client/auth/utils.py +68 -0
  7. saxo_api_client/client.py +683 -0
  8. saxo_api_client/contrib/__init__.py +23 -0
  9. saxo_api_client/contrib/client.py +359 -0
  10. saxo_api_client/contrib/option_finder.py +241 -0
  11. saxo_api_client/contrib/option_trader.py +659 -0
  12. saxo_api_client/contrib/option_types.py +87 -0
  13. saxo_api_client/contrib/orders/__init__.py +32 -0
  14. saxo_api_client/contrib/orders/baseorder.py +37 -0
  15. saxo_api_client/contrib/orders/closure.py +141 -0
  16. saxo_api_client/contrib/orders/helper.py +127 -0
  17. saxo_api_client/contrib/orders/limitorder.py +385 -0
  18. saxo_api_client/contrib/orders/marketorder.py +420 -0
  19. saxo_api_client/contrib/orders/mixin.py +33 -0
  20. saxo_api_client/contrib/orders/onfill.py +295 -0
  21. saxo_api_client/contrib/orders/stopiftradedorder.py +283 -0
  22. saxo_api_client/contrib/orders/stoplimitorder.py +295 -0
  23. saxo_api_client/contrib/orders/stoporder.py +355 -0
  24. saxo_api_client/contrib/session.py +69 -0
  25. saxo_api_client/contrib/util/__init__.py +3 -0
  26. saxo_api_client/contrib/util/instrument_to_uic.py +83 -0
  27. saxo_api_client/contrib/ws/__init__.py +0 -0
  28. saxo_api_client/contrib/ws/stream.py +45 -0
  29. saxo_api_client/definitions/__init__.py +6 -0
  30. saxo_api_client/definitions/accounthistory.py +153 -0
  31. saxo_api_client/definitions/activities.py +49 -0
  32. saxo_api_client/definitions/orders.py +197 -0
  33. saxo_api_client/definitions/reportformats.py +117 -0
  34. saxo_api_client/endpoints/__init__.py +0 -0
  35. saxo_api_client/endpoints/accounthistory/__init__.py +6 -0
  36. saxo_api_client/endpoints/accounthistory/accountvalues.py +18 -0
  37. saxo_api_client/endpoints/accounthistory/base.py +26 -0
  38. saxo_api_client/endpoints/accounthistory/historicalpositions.py +20 -0
  39. saxo_api_client/endpoints/accounthistory/performance.py +21 -0
  40. saxo_api_client/endpoints/accounthistory/performance_v4.py +21 -0
  41. saxo_api_client/endpoints/apirequest.py +64 -0
  42. saxo_api_client/endpoints/chart/__init__.py +1 -0
  43. saxo_api_client/endpoints/chart/base.py +28 -0
  44. saxo_api_client/endpoints/chart/charts.py +71 -0
  45. saxo_api_client/endpoints/decorators.py +85 -0
  46. saxo_api_client/endpoints/eventnotificationservices/__init__.py +1 -0
  47. saxo_api_client/endpoints/eventnotificationservices/base.py +28 -0
  48. saxo_api_client/endpoints/eventnotificationservices/clientactivities.py +64 -0
  49. saxo_api_client/endpoints/marketdata/__init__.py +1 -0
  50. saxo_api_client/endpoints/marketdata/documents.py +29 -0
  51. saxo_api_client/endpoints/portfolio/__init__.py +12 -0
  52. saxo_api_client/endpoints/portfolio/accountgroups.py +59 -0
  53. saxo_api_client/endpoints/portfolio/accounts.py +119 -0
  54. saxo_api_client/endpoints/portfolio/balances.py +80 -0
  55. saxo_api_client/endpoints/portfolio/base.py +28 -0
  56. saxo_api_client/endpoints/portfolio/clients.py +71 -0
  57. saxo_api_client/endpoints/portfolio/closedpositions.py +123 -0
  58. saxo_api_client/endpoints/portfolio/exposure.py +121 -0
  59. saxo_api_client/endpoints/portfolio/netpositions.py +99 -0
  60. saxo_api_client/endpoints/portfolio/orders.py +98 -0
  61. saxo_api_client/endpoints/portfolio/positions.py +112 -0
  62. saxo_api_client/endpoints/portfolio/users.py +76 -0
  63. saxo_api_client/endpoints/referencedata/__init__.py +10 -0
  64. saxo_api_client/endpoints/referencedata/algostrategies.py +43 -0
  65. saxo_api_client/endpoints/referencedata/base.py +25 -0
  66. saxo_api_client/endpoints/referencedata/countries.py +16 -0
  67. saxo_api_client/endpoints/referencedata/cultures.py +16 -0
  68. saxo_api_client/endpoints/referencedata/currencies.py +16 -0
  69. saxo_api_client/endpoints/referencedata/currencypairs.py +16 -0
  70. saxo_api_client/endpoints/referencedata/exchanges.py +43 -0
  71. saxo_api_client/endpoints/referencedata/instruments.py +76 -0
  72. saxo_api_client/endpoints/referencedata/languages.py +16 -0
  73. saxo_api_client/endpoints/referencedata/marketdata.py +1 -0
  74. saxo_api_client/endpoints/referencedata/standarddates.py +45 -0
  75. saxo_api_client/endpoints/referencedata/timezones.py +16 -0
  76. saxo_api_client/endpoints/rootservices/__init__.py +1 -0
  77. saxo_api_client/endpoints/rootservices/base.py +19 -0
  78. saxo_api_client/endpoints/rootservices/diagnostics.py +89 -0
  79. saxo_api_client/endpoints/rootservices/features.py +45 -0
  80. saxo_api_client/endpoints/rootservices/sessions.py +53 -0
  81. saxo_api_client/endpoints/rootservices/subscriptions.py +22 -0
  82. saxo_api_client/endpoints/rootservices/user.py +16 -0
  83. saxo_api_client/endpoints/trading/__init__.py +11 -0
  84. saxo_api_client/endpoints/trading/allocationkeys.py +65 -0
  85. saxo_api_client/endpoints/trading/base.py +28 -0
  86. saxo_api_client/endpoints/trading/infoprices.py +85 -0
  87. saxo_api_client/endpoints/trading/messages.py +73 -0
  88. saxo_api_client/endpoints/trading/multilegorders.py +66 -0
  89. saxo_api_client/endpoints/trading/multilegprices.py +30 -0
  90. saxo_api_client/endpoints/trading/optionschain.py +70 -0
  91. saxo_api_client/endpoints/trading/orders.py +58 -0
  92. saxo_api_client/endpoints/trading/positions.py +54 -0
  93. saxo_api_client/endpoints/trading/prices.py +58 -0
  94. saxo_api_client/endpoints/trading/prices_extensions.py +22 -0
  95. saxo_api_client/endpoints/trading/trades.py +18 -0
  96. saxo_api_client/endpoints/valueadd/__init__.py +1 -0
  97. saxo_api_client/endpoints/valueadd/base.py +28 -0
  98. saxo_api_client/endpoints/valueadd/pricealerts.py +101 -0
  99. saxo_api_client/exceptions.py +38 -0
  100. saxo_api_client/models/__init__.py +2 -0
  101. saxo_api_client/models/_base.py +15 -0
  102. saxo_api_client/models/base.py +19 -0
  103. saxo_api_client/models/generated/atr/cashmanagement-beneficiaryinstructions.py +17 -0
  104. saxo_api_client/models/generated/atr/cashmanagement-cashwithdrawal.py +22 -0
  105. saxo_api_client/models/generated/atr/cashmanagement-cashwithdrawallimits.py +17 -0
  106. saxo_api_client/models/generated/atr/cashmanagement-interaccounttransfers.py +17 -0
  107. saxo_api_client/models/generated/atr/cashmanagement-periodicpayment.py +53 -0
  108. saxo_api_client/models/generated/atr/partner-cashtransfer.py +39 -0
  109. saxo_api_client/models/generated/atr/partner-cashtransferlimits.py +17 -0
  110. saxo_api_client/models/generated/atr/partner-prefunding.py +25 -0
  111. saxo_api_client/models/generated/atr/securitiestransfers.py +151 -0
  112. saxo_api_client/models/generated/ca/elections.py +35 -0
  113. saxo_api_client/models/generated/ca/events.py +126 -0
  114. saxo_api_client/models/generated/ca/eventviews.py +102 -0
  115. saxo_api_client/models/generated/ca/holdings.py +32 -0
  116. saxo_api_client/models/generated/ca/proxyvoting.py +53 -0
  117. saxo_api_client/models/generated/ca/standinginstructions.py +64 -0
  118. saxo_api_client/models/generated/chart/charts.py +147 -0
  119. saxo_api_client/models/generated/cm/accounts.py +17 -0
  120. saxo_api_client/models/generated/cm/clientrenewals.py +349 -0
  121. saxo_api_client/models/generated/cm/documents.py +29 -0
  122. saxo_api_client/models/generated/cm/signups.py +92 -0
  123. saxo_api_client/models/generated/cm/users.py +18 -0
  124. saxo_api_client/models/generated/cr/historicalreportdata-accountstatement.py +25 -0
  125. saxo_api_client/models/generated/cr/historicalreportdata-portfoliomanagement.py +34 -0
  126. saxo_api_client/models/generated/cr/historicalreportdata-tradedetails.py +25 -0
  127. saxo_api_client/models/generated/cr/historicalreportdata-tradesexecuted.py +20 -0
  128. saxo_api_client/models/generated/cs/audit-orderactivities.py +44 -0
  129. saxo_api_client/models/generated/cs/cashmanagement-interaccounttransfer.py +20 -0
  130. saxo_api_client/models/generated/cs/cashmanagement-wiretransfers.py +18 -0
  131. saxo_api_client/models/generated/cs/clientinfo.py +30 -0
  132. saxo_api_client/models/generated/cs/historicalreportdata-aggregatedamounts.py +24 -0
  133. saxo_api_client/models/generated/cs/historicalreportdata-bookings.py +32 -0
  134. saxo_api_client/models/generated/cs/historicalreportdata-closedpositions.py +22 -0
  135. saxo_api_client/models/generated/cs/historicalreportdata-trades.py +25 -0
  136. saxo_api_client/models/generated/cs/support-cases.py +85 -0
  137. saxo_api_client/models/generated/cs/tradingconditions-contractoption.py +22 -0
  138. saxo_api_client/models/generated/cs/tradingconditions-cost.py +112 -0
  139. saxo_api_client/models/generated/cs/tradingconditions.py +108 -0
  140. saxo_api_client/models/generated/dm/disclaimermanagement.py +28 -0
  141. saxo_api_client/models/generated/ens/clientactivities.py +166 -0
  142. saxo_api_client/models/generated/hist/accountvalues.py +17 -0
  143. saxo_api_client/models/generated/hist/historicalpositions.py +111 -0
  144. saxo_api_client/models/generated/hist/performance.py +68 -0
  145. saxo_api_client/models/generated/hist/transactions.py +38 -0
  146. saxo_api_client/models/generated/hist/unsettledamounts.py +50 -0
  147. saxo_api_client/models/generated/mkt/instrumentdocument.py +117 -0
  148. saxo_api_client/models/generated/partnerintegration/advisoryaccounts.py +67 -0
  149. saxo_api_client/models/generated/partnerintegration/externalaccounts.py +58 -0
  150. saxo_api_client/models/generated/partnerintegration/fundinginstruction.py +42 -0
  151. saxo_api_client/models/generated/partnerintegration/interactiveesigning.py +39 -0
  152. saxo_api_client/models/generated/partnerintegration/interactiveidverification.py +29 -0
  153. saxo_api_client/models/generated/partnerintegration/partnerbulkbookings.py +22 -0
  154. saxo_api_client/models/generated/partnerintegration/updatepricing.py +104 -0
  155. saxo_api_client/models/generated/port/accountgroups.py +91 -0
  156. saxo_api_client/models/generated/port/accounts.py +335 -0
  157. saxo_api_client/models/generated/port/balances.py +494 -0
  158. saxo_api_client/models/generated/port/clients.py +254 -0
  159. saxo_api_client/models/generated/port/closedpositions.py +306 -0
  160. saxo_api_client/models/generated/port/exposure.py +285 -0
  161. saxo_api_client/models/generated/port/netpositions.py +485 -0
  162. saxo_api_client/models/generated/port/orders.py +615 -0
  163. saxo_api_client/models/generated/port/positions.py +548 -0
  164. saxo_api_client/models/generated/port/users.py +195 -0
  165. saxo_api_client/models/generated/ref/algostrategies.py +20 -0
  166. saxo_api_client/models/generated/ref/countries.py +26 -0
  167. saxo_api_client/models/generated/ref/cultures.py +23 -0
  168. saxo_api_client/models/generated/ref/currencies.py +25 -0
  169. saxo_api_client/models/generated/ref/currencypairs.py +17 -0
  170. saxo_api_client/models/generated/ref/exchanges.py +20 -0
  171. saxo_api_client/models/generated/ref/instruments.py +163 -0
  172. saxo_api_client/models/generated/ref/languages.py +24 -0
  173. saxo_api_client/models/generated/ref/standarddates.py +20 -0
  174. saxo_api_client/models/generated/ref/timezones.py +26 -0
  175. saxo_api_client/models/generated/reg/financialoverview.py +65 -0
  176. saxo_api_client/models/generated/reg/investmentprofile.py +76 -0
  177. saxo_api_client/models/generated/reg/knowledgeandexperience.py +25 -0
  178. saxo_api_client/models/generated/trade/allocationkeys.py +145 -0
  179. saxo_api_client/models/generated/trade/infoprices.py +172 -0
  180. saxo_api_client/models/generated/trade/messages.py +60 -0
  181. saxo_api_client/models/generated/trade/optionschain.py +125 -0
  182. saxo_api_client/models/generated/trade/orders.py +365 -0
  183. saxo_api_client/models/generated/trade/positions.py +115 -0
  184. saxo_api_client/models/generated/trade/prices.py +205 -0
  185. saxo_api_client/models/generated/trade/trades.py +131 -0
  186. saxo_api_client/models/generated/vas/pricealerts.py +165 -0
  187. saxo_api_client/models/openapi.py +5296 -0
  188. saxo_api_client/models/portfolio.py +86 -0
  189. saxo_api_client/trace.py +277 -0
  190. saxo_api_client/types/__init__.py +56 -0
  191. saxo_api_client/types/primitives.py +62 -0
  192. saxo_api_client/types/responses.py +106 -0
  193. saxo_api_client-1.0.0.dist-info/METADATA +232 -0
  194. saxo_api_client-1.0.0.dist-info/RECORD +196 -0
  195. saxo_api_client-1.0.0.dist-info/WHEEL +4 -0
  196. saxo_api_client-1.0.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,22 @@
1
+ # サブモジュールをインポート
2
+ from . import contrib, endpoints
3
+ from .exceptions import OpenAPIError
4
+ from .client import API
5
+
6
+ # よく使われる定義(Enum)をトップレベルに露出
7
+ from .definitions.orders import AssetType, OrderType, OrderDurationType, Direction
8
+
9
+ # バージョン情報
10
+ __version__ = "1.0.0"
11
+
12
+ # APIを直接インポートできるようにする
13
+ __all__ = [
14
+ "API",
15
+ "OpenAPIError",
16
+ "contrib",
17
+ "endpoints",
18
+ "AssetType",
19
+ "OrderType",
20
+ "OrderDurationType",
21
+ "Direction"
22
+ ]
@@ -0,0 +1,35 @@
1
+ """saxo_api_client.auth — Saxo OpenAPI 認証サブモジュール。
2
+
3
+ 使用例::
4
+
5
+ from saxo_api_client.auth import SaxoAuthClient
6
+ from saxo_api_client.auth.models import TokenData, TokenExpiredError
7
+ from saxo_api_client.auth.utils import construct_auth_url, validate_redirect_url
8
+ """
9
+
10
+ from .client import SaxoAuthClient, SaxoOpenAPIClient
11
+ from .models import (
12
+ APIEnvironment,
13
+ AuthorizationCode,
14
+ NotLoggedInError,
15
+ OpenAPIAppConfig,
16
+ TokenData,
17
+ TokenExpiredError,
18
+ )
19
+ from .utils import construct_auth_url, validate_redirect_url
20
+
21
+ __all__ = [
22
+ # クライアント
23
+ "SaxoAuthClient",
24
+ "SaxoOpenAPIClient", # 後方互換エイリアス
25
+ # モデル
26
+ "APIEnvironment",
27
+ "AuthorizationCode",
28
+ "NotLoggedInError",
29
+ "OpenAPIAppConfig",
30
+ "TokenData",
31
+ "TokenExpiredError",
32
+ # ユーティリティ
33
+ "construct_auth_url",
34
+ "validate_redirect_url",
35
+ ]
@@ -0,0 +1,339 @@
1
+ """Saxo Bank OpenAPI Authentication Client.
2
+
3
+ Migrated from saxo-apy (https://github.com/nohikomiso/saxo-apy).
4
+ Original copyright (c) 2022 Gid van der Ven, MIT License.
5
+ Modifications (c) 2025 nohikomiso, MIT License.
6
+
7
+ このモジュールは認証(OAuth2 ログイン・トークン取得・リフレッシュ)機能のみを提供します。
8
+ API リクエスト(GET/POST 等)は saxo_api_client 本体の機能を使用してください。
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import threading
14
+ import webbrowser
15
+ from collections.abc import Callable
16
+ from datetime import datetime
17
+ from secrets import token_urlsafe
18
+ from time import sleep, time
19
+ from urllib.parse import parse_qs
20
+
21
+ from httpx import post
22
+ from loguru import logger
23
+ from pydantic import AnyHttpUrl, ValidationError
24
+
25
+ from .models import (
26
+ APIEnvironment,
27
+ AuthorizationCode,
28
+ NotLoggedInError,
29
+ OpenAPIAppConfig,
30
+ TokenData,
31
+ TokenExpiredError,
32
+ )
33
+ from .redirect_server import RedirectServer
34
+ from .utils import (
35
+ configure_logger,
36
+ construct_auth_url,
37
+ parse_obj_as,
38
+ unix_seconds_to_datetime,
39
+ validate_redirect_url,
40
+ )
41
+
42
+ logger.remove() # デフォルトのコンソールロガーを削除
43
+
44
+
45
+ class SaxoAuthClient:
46
+ """Saxo OpenAPI 認証クライアント。
47
+
48
+ OAuth2 によるログイン・トークン取得・リフレッシュを担当します。
49
+ API リクエストは saxo_api_client 本体の機能(API)を使用してください。
50
+
51
+ Parameters
52
+ ----------
53
+ app_config:
54
+ Developer Portal から取得したアプリ設定。辞書またはJSONファイルパス。
55
+ デフォルトは "app_config.json"。
56
+ log_sink:
57
+ ログ出力先(ファイルパス等)。省略時はログ出力なし。
58
+ log_level:
59
+ ログレベル(DEBUG / INFO / WARNING / ERROR)。デフォルト "DEBUG"。
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ app_config: dict | str | None = "app_config.json",
65
+ log_sink: str | None = None,
66
+ log_level: str = "DEBUG",
67
+ on_token_refresh: Callable[[TokenData], None] | None = None,
68
+ ) -> None:
69
+ if log_sink:
70
+ configure_logger(log_sink, log_level)
71
+
72
+ self.on_token_refresh = on_token_refresh
73
+
74
+ self.client_session_id: str = token_urlsafe(10)
75
+ logger.debug(f"initializing Auth Client with session id: {self.client_session_id}")
76
+
77
+ self._app_config: OpenAPIAppConfig
78
+
79
+ try:
80
+ if isinstance(app_config, dict):
81
+ logger.debug("辞書からアプリ設定を初期化")
82
+ self._app_config = parse_obj_as(OpenAPIAppConfig, app_config)
83
+ elif isinstance(app_config, str):
84
+ logger.debug(f"ファイルからアプリ設定を初期化: {app_config}")
85
+ with open(app_config) as f:
86
+ config = json.load(f)
87
+ self._app_config = parse_obj_as(OpenAPIAppConfig, config)
88
+ else:
89
+ raise RuntimeError(f"invalid type provided for 'app_config': {type(app_config)}")
90
+ except Exception as e:
91
+ logger.error(f"アプリ設定の初期化に失敗: {e}")
92
+ raise
93
+
94
+ self._token_data: TokenData | None = None
95
+ logger.success("successfully parsed app config and initialized Auth Client")
96
+
97
+ # ------------------------------------------------------------------ #
98
+ # ログイン・ログアウト #
99
+ # ------------------------------------------------------------------ #
100
+
101
+ def login(
102
+ self,
103
+ redirect_url: AnyHttpUrl | None = None,
104
+ launch_browser: bool = True,
105
+ catch_redirect: bool = True,
106
+ start_async_refresh: bool = False,
107
+ ) -> None:
108
+ """Saxo OpenAPI へ OAuth2 ログインします。
109
+
110
+ Parameters
111
+ ----------
112
+ redirect_url:
113
+ ログイン後のリダイレクト先。省略時は設定の最初の localhost URL。
114
+ launch_browser:
115
+ True でブラウザを自動で開きます。
116
+ catch_redirect:
117
+ True でリダイレクトを受け取るローカルサーバーを起動します。
118
+ start_async_refresh:
119
+ True でアクセストークンの自動リフレッシュを開始します(Jupyter 向け)。
120
+ """
121
+ logger.debug(f"initializing login sequence with {redirect_url=}, {launch_browser=} {catch_redirect=} {start_async_refresh=}")
122
+ _redirect_url = validate_redirect_url(self._app_config, redirect_url)
123
+ state = token_urlsafe(20)
124
+ auth_url = construct_auth_url(self._app_config, _redirect_url, state)
125
+ logger.debug(f"logging in with {str(_redirect_url)=} and {str(auth_url)=}")
126
+
127
+ if catch_redirect:
128
+ redirect_server = RedirectServer(_redirect_url, state=state)
129
+ redirect_server.start()
130
+
131
+ if launch_browser:
132
+ logger.debug("launching browser with login page")
133
+ print("🌐 opening login page in browser - waiting for user to authenticate... 🔑")
134
+ webbrowser.open_new(str(auth_url))
135
+ else:
136
+ print(f"🌐 navigate to the following web page to log in: {auth_url}")
137
+
138
+ if catch_redirect:
139
+ try:
140
+ while not redirect_server.auth_code:
141
+ sleep(0.1)
142
+ print("📞 received callback from Saxo SSO")
143
+ _auth_code = parse_obj_as(AuthorizationCode, redirect_server.auth_code)
144
+ except KeyboardInterrupt:
145
+ print("🛑 operation interrupted by user - shutting down")
146
+ return
147
+ finally:
148
+ redirect_server.shutdown()
149
+ else:
150
+ auth_code_obtained = False
151
+ while not auth_code_obtained:
152
+ try:
153
+ user_input = input("📋 認証コードまたはリダイレクトURLを貼り付けてください: ").strip()
154
+
155
+ if user_input.startswith(("http://", "https://")):
156
+ redirect_location = parse_obj_as(AnyHttpUrl, user_input)
157
+ parsed_qs = parse_qs(redirect_location.query)
158
+ _auth_code = parse_obj_as(AuthorizationCode, parsed_qs["code"][0])
159
+ else:
160
+ _auth_code = parse_obj_as(AuthorizationCode, user_input)
161
+
162
+ auth_code_obtained = True
163
+
164
+ except ValidationError as e:
165
+ print(f"❌ 入力形式が正しくありません。認証コード(GUID形式)またはリダイレクトURLを入力してください: {e}")
166
+ except (KeyError, IndexError):
167
+ print("❌ URLにcodeパラメータが見つかりません。正しいリダイレクトURLを入力してください。")
168
+ except KeyboardInterrupt:
169
+ print("🛑 operation interrupted by user - shutting down")
170
+ return
171
+
172
+ self.get_tokens(auth_code=_auth_code)
173
+
174
+ assert self._token_data
175
+
176
+ env = self._app_config.env.value # type: ignore[union-attr]
177
+ perm = "WRITE / TRADE" if self._token_data.write_permission else "READ"
178
+
179
+ print(f"✅ authorization succeeded - connected to {env} environment with {perm} permissions (session ID {self._token_data.session_id})")
180
+
181
+ if self._app_config.env is APIEnvironment.LIVE and self._token_data.write_permission:
182
+ print("❗ NOTE: you are now connected to a real-money client in the LIVE environment with WRITE & TRADE permissions")
183
+
184
+ if start_async_refresh:
185
+ asyncio.create_task(self.async_refresh(), name="async_refresh")
186
+
187
+ logger.success("login completed successfully")
188
+
189
+ def logout(self) -> None:
190
+ """セッションをリセットしてトークンを削除します。"""
191
+ assert self.logged_in
192
+ logger.debug("disconnecting from OpenAPI")
193
+ self._token_data = None
194
+ refresh_thread = [thread for thread in threading.enumerate() if thread.name == "RefreshThread"]
195
+ if len(refresh_thread) > 0 and refresh_thread[0].is_alive():
196
+ refresh_thread[0].cancel() # type: ignore[attr-defined]
197
+ logger.success("logout completed")
198
+
199
+ # ------------------------------------------------------------------ #
200
+ # トークン取得・リフレッシュ #
201
+ # ------------------------------------------------------------------ #
202
+
203
+ def refresh(self) -> None:
204
+ """リフレッシュトークンを使ってアクセストークンを更新します。"""
205
+ logger.debug("refreshing API session")
206
+
207
+ if not self._token_data:
208
+ raise NotLoggedInError("no active session found - connect the client with '.login()'")
209
+
210
+ if time() > self._token_data.refresh_token_expiry:
211
+ raise TokenExpiredError("refresh token has expired - reconnect the client with '.login()'")
212
+
213
+ self.get_tokens()
214
+ logger.debug("successfully refreshed API session")
215
+
216
+ async def async_refresh(self) -> None:
217
+ """アクセストークンを非同期で自動リフレッシュします(Jupyter 向け)。"""
218
+ while self._token_data and self.refresh_token_valid:
219
+ current_time = int(time())
220
+ time_to_expiry = self._token_data.access_token_expiry - current_time
221
+ delay = max(time_to_expiry - 30, 1)
222
+ logger.debug(f"async refresh will kick off refresh flow in {delay} seconds at: {unix_seconds_to_datetime(current_time + delay)}")
223
+ await asyncio.sleep(delay)
224
+
225
+ if not self.refresh_token_valid:
226
+ logger.warning("refresh token expired - stopping async refresh")
227
+ break
228
+
229
+ logger.debug("async refresh delay has passed - kicking off refresh")
230
+ try:
231
+ self.refresh()
232
+ except TokenExpiredError:
233
+ logger.error("refresh token expired during async refresh - stopping")
234
+ break
235
+ logger.debug("async refresh stopped")
236
+
237
+ def get_tokens(self, auth_code: AuthorizationCode | None = None) -> None:
238
+ """認証コードまたはリフレッシュトークンを使ってトークンペアを取得します。"""
239
+ authorization_param = "code" if auth_code else "refresh_token"
240
+ grant_type = "authorization_code" if auth_code else "refresh_token"
241
+ logger.debug(f"exercising authorization with grant type: {grant_type}")
242
+
243
+ token_request_params = {
244
+ "grant_type": grant_type,
245
+ authorization_param: auth_code or self._token_data.refresh_token, # type: ignore[union-attr]
246
+ "client_id": self._app_config.client_id,
247
+ "client_secret": self._app_config.client_secret,
248
+ }
249
+ response = post(
250
+ str(self._app_config.token_endpoint),
251
+ params=token_request_params,
252
+ headers={
253
+ "user-agent": "saxo-api-client-auth/1.0",
254
+ },
255
+ )
256
+
257
+ if response.status_code == 401:
258
+ raise TokenExpiredError("refresh token has expired or is invalid - reconnect the client with '.login()'")
259
+ elif response.status_code != 201:
260
+ try:
261
+ error_detail = response.json()
262
+ error_message = error_detail.get("error_description", "unknown error")
263
+ except Exception:
264
+ error_message = f"HTTP {response.status_code}"
265
+
266
+ raise RuntimeError(f"unexpected error occurred while retrieving token - response status: {response.status_code}, detail: {error_message}")
267
+ logger.success("successfully exercised authorization - new token data retrieved")
268
+
269
+ received_token_data = response.json()
270
+ try:
271
+ self._token_data = TokenData.model_validate(received_token_data)
272
+ logger.debug("Token data validated successfully with model_validate")
273
+ if self.on_token_refresh:
274
+ try:
275
+ self.on_token_refresh(self._token_data)
276
+ except Exception as e:
277
+ logger.error(f"Error in on_token_refresh callback: {e}")
278
+ except Exception as e:
279
+ logger.error(f"Failed to validate token data: {e}")
280
+ raise RuntimeError(f"トークンデータの検証に失敗しました: {e}") from e
281
+
282
+ # ------------------------------------------------------------------ #
283
+ # プロパティ #
284
+ # ------------------------------------------------------------------ #
285
+
286
+ @property
287
+ def available_redirect_urls(self) -> list[AnyHttpUrl]:
288
+ """設定から利用可能なリダイレクトURLを返します。"""
289
+ return self._app_config.redirect_urls
290
+
291
+ @property
292
+ def logged_in(self) -> bool:
293
+ """有効なセッションがあるか確認します。"""
294
+ if not self._token_data:
295
+ raise NotLoggedInError("no active session found - connect the client with '.login()'")
296
+ if time() > self._token_data.access_token_expiry:
297
+ raise TokenExpiredError("access token has expired - reconnect the client with '.login()'")
298
+ return True
299
+
300
+ @property
301
+ def access_token(self) -> str:
302
+ """現在有効なアクセストークンを返します。"""
303
+ assert self.logged_in
304
+ assert self._token_data
305
+ return self._token_data.access_token
306
+
307
+ @property
308
+ def token_data(self) -> TokenData:
309
+ """トークンデータ全体を返します。"""
310
+ assert self.logged_in
311
+ assert self._token_data
312
+ return self._token_data
313
+
314
+ @property
315
+ def access_token_expiry(self) -> datetime:
316
+ """アクセストークンの有効期限(datetime)を返します。"""
317
+ assert self.logged_in
318
+ return unix_seconds_to_datetime(
319
+ self._token_data.access_token_expiry # type: ignore[union-attr]
320
+ )
321
+
322
+ @property
323
+ def time_to_expiry(self) -> int:
324
+ """アクセストークンの残り有効秒数を返します。"""
325
+ assert self.logged_in
326
+ token_expiry = self._token_data.access_token_expiry # type: ignore[union-attr]
327
+ return token_expiry - int(time())
328
+
329
+ @property
330
+ def refresh_token_valid(self) -> bool:
331
+ """リフレッシュトークンがまだ有効かどうかを返します。"""
332
+ if not self._token_data:
333
+ return False
334
+ return time() <= self._token_data.refresh_token_expiry
335
+
336
+
337
+ # 後方互換性のためのエイリアス
338
+ # 旧コードが `SaxoOpenAPIClient` でインポートしていた箇所に対応
339
+ SaxoOpenAPIClient = SaxoAuthClient
@@ -0,0 +1,223 @@
1
+ """Data Models for Saxo OpenAPI Authentication.
2
+
3
+ Migrated from saxo-apy (https://github.com/nohikomiso/saxo-apy).
4
+ Original copyright (c) 2022 Gid van der Ven, MIT License.
5
+ Modifications (c) 2025 nohikomiso, MIT License.
6
+ """
7
+
8
+ import json
9
+ from base64 import urlsafe_b64decode
10
+ from datetime import datetime
11
+ from enum import Enum
12
+ from time import time
13
+ from typing import Any
14
+
15
+ from pydantic import (
16
+ AnyHttpUrl,
17
+ AnyUrl,
18
+ BaseModel,
19
+ ConfigDict,
20
+ Field,
21
+ GetCoreSchemaHandler,
22
+ field_validator,
23
+ model_validator,
24
+ )
25
+ from pydantic_core import core_schema
26
+
27
+ SIM_STREAMING_URL = "wss://streaming.saxobank.com/sim/oapi/streaming/ws"
28
+ LIVE_STREAMING_URL = "wss://streaming.saxobank.com/oapi/streaming/ws"
29
+
30
+
31
+ class ClientId(str):
32
+ """ClientId. 32 char string."""
33
+
34
+ @classmethod
35
+ def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler):
36
+ def validate(v):
37
+ import re
38
+
39
+ if not re.match(r"^[a-f0-9]{32}$", v):
40
+ raise ValueError("Invalid ClientId format")
41
+ return v
42
+
43
+ return core_schema.no_info_plain_validator_function(validate)
44
+
45
+
46
+ class ClientSecret(ClientId):
47
+ """CLientSecret. Same as ClientId."""
48
+
49
+ pass
50
+
51
+
52
+ class HttpsUrl(AnyUrl):
53
+ """HTTPS URL. Override AnyUrl to only allow for secure protocol."""
54
+
55
+ allowed_schemes = {"https"}
56
+
57
+
58
+ class GrantType(Enum):
59
+ """OAuth grant type. Only supported version is Code."""
60
+
61
+ CODE = "Code"
62
+
63
+
64
+ class APIEnvironment(Enum):
65
+ """OpenAPI Environment. SIM and LIVE are currently supported."""
66
+
67
+ SIM = "SIM"
68
+ LIVE = "LIVE"
69
+
70
+
71
+ class AuthorizationCode(str):
72
+ """Auth code. GUID."""
73
+
74
+ @classmethod
75
+ def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler):
76
+ def validate(v):
77
+ import re
78
+
79
+ if not re.match(r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", v):
80
+ raise ValueError("Invalid AuthorizationCode format")
81
+ return v
82
+
83
+ return core_schema.no_info_plain_validator_function(validate)
84
+
85
+
86
+ class RefreshToken(AuthorizationCode):
87
+ """Refresh token. Same as Auth code (GUID)."""
88
+
89
+ pass
90
+
91
+
92
+ class AuthorizationType(Enum):
93
+ """Supported auth types. Either a auth code or refresh token can be exercised."""
94
+
95
+ CODE = "authorization_code"
96
+ REFRESH_TOKEN = "refresh_token"
97
+
98
+
99
+ class OpenAPIAppConfig(BaseModel):
100
+ """Dataclass for parsing and validating app config objects."""
101
+
102
+ model_config = ConfigDict(extra="forbid")
103
+ app_name: str = Field(..., alias="AppName")
104
+ grant_type: GrantType = Field(..., alias="GrantType")
105
+ client_id: ClientId = Field(..., alias="AppKey")
106
+ client_secret: ClientSecret = Field(..., alias="AppSecret")
107
+ auth_endpoint: HttpsUrl = Field(..., alias="AuthorizationEndpoint")
108
+ token_endpoint: HttpsUrl = Field(..., alias="TokenEndpoint")
109
+ api_base_url: HttpsUrl = Field(..., alias="OpenApiBaseUrl")
110
+ streaming_url: HttpsUrl | None = None
111
+ redirect_urls: list[AnyHttpUrl] = Field(..., alias="RedirectUrls")
112
+ env: APIEnvironment | None = None
113
+
114
+ @model_validator(mode="after")
115
+ def validate_redirect_urls_contains_localhost(self):
116
+ """Redirect URLs must at least have 1 localhost available."""
117
+ available_hosts = [url.host for url in self.redirect_urls]
118
+ assert "localhost" in available_hosts, f"at least 1 'localhost' redirect URL required in app config - hosts: {available_hosts}"
119
+ return self
120
+
121
+ @model_validator(mode="after")
122
+ def validate_port_configuration_redirect_urls(self):
123
+ """Port configuration validation - only warn for HTTP URLs without explicit ports."""
124
+ for url in self.redirect_urls:
125
+ url_str = str(url)
126
+ if url.scheme == "https":
127
+ continue
128
+ if url.scheme == "http" and ":" + str(url.port) not in url_str:
129
+ pass
130
+ return self
131
+
132
+ @model_validator(mode="after")
133
+ def strip_base_url_suffix(self) -> "OpenAPIAppConfig":
134
+ """Strip forward slash form base URL."""
135
+ if self.api_base_url:
136
+ self.api_base_url = HttpsUrl(str(self.api_base_url).rstrip("/"))
137
+ return self
138
+
139
+ @model_validator(mode="after")
140
+ def derive_env_fields(self) -> "OpenAPIAppConfig":
141
+ """Set environment and streaming URL based on environment."""
142
+ if "sim.logonvalidation" in str(self.auth_endpoint):
143
+ self.env = APIEnvironment.SIM
144
+ self.streaming_url = HttpsUrl(SIM_STREAMING_URL)
145
+ if "live.logonvalidation" in str(self.auth_endpoint):
146
+ self.env = APIEnvironment.LIVE
147
+ self.streaming_url = HttpsUrl(LIVE_STREAMING_URL)
148
+ return self
149
+
150
+ @classmethod
151
+ def parse_obj(cls, obj: Any) -> "OpenAPIAppConfig":
152
+ """Pydantic v1 compatibility shim."""
153
+ try:
154
+ return cls.model_validate(obj)
155
+ except Exception as e:
156
+ from loguru import logger
157
+ logger.error(f"OpenAPIAppConfig.parse_obj互換レイヤーでのエラー: {e}")
158
+ raise
159
+
160
+
161
+ class TokenData(BaseModel):
162
+ """Dataclass for parsing token data."""
163
+
164
+ access_token: str
165
+ token_type: str
166
+ expires_in: int
167
+ refresh_token: RefreshToken
168
+ refresh_token_expires_in: int
169
+ base_uri: HttpsUrl | None
170
+ access_token_expiry: int
171
+ refresh_token_expiry: int
172
+ client_key: str
173
+ user_key: str
174
+ session_id: str
175
+ write_permission: bool
176
+
177
+ @model_validator(mode="before")
178
+ def set_fields_from_token_payload(cls, values: dict) -> dict:
179
+ """Set fields from token claims."""
180
+ token_bytes = values["access_token"].encode("utf-8")
181
+ payload = token_bytes.split(b".")[1]
182
+ padded = payload + b"=" * divmod(len(payload), 4)[1]
183
+ decoded = urlsafe_b64decode(padded)
184
+ claims = json.loads(decoded.decode("utf-8"))
185
+
186
+ values["access_token_expiry"] = claims["exp"]
187
+ values["refresh_token_expiry"] = int(time()) + values["refresh_token_expires_in"]
188
+ values["client_key"] = claims["cid"]
189
+ values["user_key"] = claims["uid"]
190
+ values["session_id"] = claims["sid"]
191
+ values["write_permission"] = claims.get("oaa") == "77770"
192
+
193
+ return values
194
+
195
+ @classmethod
196
+ def parse_obj(cls, obj: Any) -> "TokenData":
197
+ """Pydantic v1 compatibility shim."""
198
+ try:
199
+ return cls.model_validate(obj)
200
+ except Exception as e:
201
+ from loguru import logger
202
+ logger.error(f"TokenData.parse_obj互換レイヤーでのエラー: {e}")
203
+ raise
204
+
205
+
206
+ class NotLoggedInError(Exception):
207
+ """Client is not logged in."""
208
+ pass
209
+
210
+
211
+ class TokenExpiredError(Exception):
212
+ """Token has expired and can no longer be used."""
213
+ pass
214
+
215
+
216
+ class APIRequestError(Exception):
217
+ """An error occurred while creating the OpenAPI request."""
218
+ pass
219
+
220
+
221
+ class APIResponseError(Exception):
222
+ """An error occurred while executing the OpenAPI request."""
223
+ pass