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.
- saxo_api_client/__init__.py +22 -0
- saxo_api_client/auth/__init__.py +35 -0
- saxo_api_client/auth/client.py +339 -0
- saxo_api_client/auth/models.py +223 -0
- saxo_api_client/auth/redirect_server.py +82 -0
- saxo_api_client/auth/utils.py +68 -0
- saxo_api_client/client.py +683 -0
- saxo_api_client/contrib/__init__.py +23 -0
- saxo_api_client/contrib/client.py +359 -0
- saxo_api_client/contrib/option_finder.py +241 -0
- saxo_api_client/contrib/option_trader.py +659 -0
- saxo_api_client/contrib/option_types.py +87 -0
- saxo_api_client/contrib/orders/__init__.py +32 -0
- saxo_api_client/contrib/orders/baseorder.py +37 -0
- saxo_api_client/contrib/orders/closure.py +141 -0
- saxo_api_client/contrib/orders/helper.py +127 -0
- saxo_api_client/contrib/orders/limitorder.py +385 -0
- saxo_api_client/contrib/orders/marketorder.py +420 -0
- saxo_api_client/contrib/orders/mixin.py +33 -0
- saxo_api_client/contrib/orders/onfill.py +295 -0
- saxo_api_client/contrib/orders/stopiftradedorder.py +283 -0
- saxo_api_client/contrib/orders/stoplimitorder.py +295 -0
- saxo_api_client/contrib/orders/stoporder.py +355 -0
- saxo_api_client/contrib/session.py +69 -0
- saxo_api_client/contrib/util/__init__.py +3 -0
- saxo_api_client/contrib/util/instrument_to_uic.py +83 -0
- saxo_api_client/contrib/ws/__init__.py +0 -0
- saxo_api_client/contrib/ws/stream.py +45 -0
- saxo_api_client/definitions/__init__.py +6 -0
- saxo_api_client/definitions/accounthistory.py +153 -0
- saxo_api_client/definitions/activities.py +49 -0
- saxo_api_client/definitions/orders.py +197 -0
- saxo_api_client/definitions/reportformats.py +117 -0
- saxo_api_client/endpoints/__init__.py +0 -0
- saxo_api_client/endpoints/accounthistory/__init__.py +6 -0
- saxo_api_client/endpoints/accounthistory/accountvalues.py +18 -0
- saxo_api_client/endpoints/accounthistory/base.py +26 -0
- saxo_api_client/endpoints/accounthistory/historicalpositions.py +20 -0
- saxo_api_client/endpoints/accounthistory/performance.py +21 -0
- saxo_api_client/endpoints/accounthistory/performance_v4.py +21 -0
- saxo_api_client/endpoints/apirequest.py +64 -0
- saxo_api_client/endpoints/chart/__init__.py +1 -0
- saxo_api_client/endpoints/chart/base.py +28 -0
- saxo_api_client/endpoints/chart/charts.py +71 -0
- saxo_api_client/endpoints/decorators.py +85 -0
- saxo_api_client/endpoints/eventnotificationservices/__init__.py +1 -0
- saxo_api_client/endpoints/eventnotificationservices/base.py +28 -0
- saxo_api_client/endpoints/eventnotificationservices/clientactivities.py +64 -0
- saxo_api_client/endpoints/marketdata/__init__.py +1 -0
- saxo_api_client/endpoints/marketdata/documents.py +29 -0
- saxo_api_client/endpoints/portfolio/__init__.py +12 -0
- saxo_api_client/endpoints/portfolio/accountgroups.py +59 -0
- saxo_api_client/endpoints/portfolio/accounts.py +119 -0
- saxo_api_client/endpoints/portfolio/balances.py +80 -0
- saxo_api_client/endpoints/portfolio/base.py +28 -0
- saxo_api_client/endpoints/portfolio/clients.py +71 -0
- saxo_api_client/endpoints/portfolio/closedpositions.py +123 -0
- saxo_api_client/endpoints/portfolio/exposure.py +121 -0
- saxo_api_client/endpoints/portfolio/netpositions.py +99 -0
- saxo_api_client/endpoints/portfolio/orders.py +98 -0
- saxo_api_client/endpoints/portfolio/positions.py +112 -0
- saxo_api_client/endpoints/portfolio/users.py +76 -0
- saxo_api_client/endpoints/referencedata/__init__.py +10 -0
- saxo_api_client/endpoints/referencedata/algostrategies.py +43 -0
- saxo_api_client/endpoints/referencedata/base.py +25 -0
- saxo_api_client/endpoints/referencedata/countries.py +16 -0
- saxo_api_client/endpoints/referencedata/cultures.py +16 -0
- saxo_api_client/endpoints/referencedata/currencies.py +16 -0
- saxo_api_client/endpoints/referencedata/currencypairs.py +16 -0
- saxo_api_client/endpoints/referencedata/exchanges.py +43 -0
- saxo_api_client/endpoints/referencedata/instruments.py +76 -0
- saxo_api_client/endpoints/referencedata/languages.py +16 -0
- saxo_api_client/endpoints/referencedata/marketdata.py +1 -0
- saxo_api_client/endpoints/referencedata/standarddates.py +45 -0
- saxo_api_client/endpoints/referencedata/timezones.py +16 -0
- saxo_api_client/endpoints/rootservices/__init__.py +1 -0
- saxo_api_client/endpoints/rootservices/base.py +19 -0
- saxo_api_client/endpoints/rootservices/diagnostics.py +89 -0
- saxo_api_client/endpoints/rootservices/features.py +45 -0
- saxo_api_client/endpoints/rootservices/sessions.py +53 -0
- saxo_api_client/endpoints/rootservices/subscriptions.py +22 -0
- saxo_api_client/endpoints/rootservices/user.py +16 -0
- saxo_api_client/endpoints/trading/__init__.py +11 -0
- saxo_api_client/endpoints/trading/allocationkeys.py +65 -0
- saxo_api_client/endpoints/trading/base.py +28 -0
- saxo_api_client/endpoints/trading/infoprices.py +85 -0
- saxo_api_client/endpoints/trading/messages.py +73 -0
- saxo_api_client/endpoints/trading/multilegorders.py +66 -0
- saxo_api_client/endpoints/trading/multilegprices.py +30 -0
- saxo_api_client/endpoints/trading/optionschain.py +70 -0
- saxo_api_client/endpoints/trading/orders.py +58 -0
- saxo_api_client/endpoints/trading/positions.py +54 -0
- saxo_api_client/endpoints/trading/prices.py +58 -0
- saxo_api_client/endpoints/trading/prices_extensions.py +22 -0
- saxo_api_client/endpoints/trading/trades.py +18 -0
- saxo_api_client/endpoints/valueadd/__init__.py +1 -0
- saxo_api_client/endpoints/valueadd/base.py +28 -0
- saxo_api_client/endpoints/valueadd/pricealerts.py +101 -0
- saxo_api_client/exceptions.py +38 -0
- saxo_api_client/models/__init__.py +2 -0
- saxo_api_client/models/_base.py +15 -0
- saxo_api_client/models/base.py +19 -0
- saxo_api_client/models/generated/atr/cashmanagement-beneficiaryinstructions.py +17 -0
- saxo_api_client/models/generated/atr/cashmanagement-cashwithdrawal.py +22 -0
- saxo_api_client/models/generated/atr/cashmanagement-cashwithdrawallimits.py +17 -0
- saxo_api_client/models/generated/atr/cashmanagement-interaccounttransfers.py +17 -0
- saxo_api_client/models/generated/atr/cashmanagement-periodicpayment.py +53 -0
- saxo_api_client/models/generated/atr/partner-cashtransfer.py +39 -0
- saxo_api_client/models/generated/atr/partner-cashtransferlimits.py +17 -0
- saxo_api_client/models/generated/atr/partner-prefunding.py +25 -0
- saxo_api_client/models/generated/atr/securitiestransfers.py +151 -0
- saxo_api_client/models/generated/ca/elections.py +35 -0
- saxo_api_client/models/generated/ca/events.py +126 -0
- saxo_api_client/models/generated/ca/eventviews.py +102 -0
- saxo_api_client/models/generated/ca/holdings.py +32 -0
- saxo_api_client/models/generated/ca/proxyvoting.py +53 -0
- saxo_api_client/models/generated/ca/standinginstructions.py +64 -0
- saxo_api_client/models/generated/chart/charts.py +147 -0
- saxo_api_client/models/generated/cm/accounts.py +17 -0
- saxo_api_client/models/generated/cm/clientrenewals.py +349 -0
- saxo_api_client/models/generated/cm/documents.py +29 -0
- saxo_api_client/models/generated/cm/signups.py +92 -0
- saxo_api_client/models/generated/cm/users.py +18 -0
- saxo_api_client/models/generated/cr/historicalreportdata-accountstatement.py +25 -0
- saxo_api_client/models/generated/cr/historicalreportdata-portfoliomanagement.py +34 -0
- saxo_api_client/models/generated/cr/historicalreportdata-tradedetails.py +25 -0
- saxo_api_client/models/generated/cr/historicalreportdata-tradesexecuted.py +20 -0
- saxo_api_client/models/generated/cs/audit-orderactivities.py +44 -0
- saxo_api_client/models/generated/cs/cashmanagement-interaccounttransfer.py +20 -0
- saxo_api_client/models/generated/cs/cashmanagement-wiretransfers.py +18 -0
- saxo_api_client/models/generated/cs/clientinfo.py +30 -0
- saxo_api_client/models/generated/cs/historicalreportdata-aggregatedamounts.py +24 -0
- saxo_api_client/models/generated/cs/historicalreportdata-bookings.py +32 -0
- saxo_api_client/models/generated/cs/historicalreportdata-closedpositions.py +22 -0
- saxo_api_client/models/generated/cs/historicalreportdata-trades.py +25 -0
- saxo_api_client/models/generated/cs/support-cases.py +85 -0
- saxo_api_client/models/generated/cs/tradingconditions-contractoption.py +22 -0
- saxo_api_client/models/generated/cs/tradingconditions-cost.py +112 -0
- saxo_api_client/models/generated/cs/tradingconditions.py +108 -0
- saxo_api_client/models/generated/dm/disclaimermanagement.py +28 -0
- saxo_api_client/models/generated/ens/clientactivities.py +166 -0
- saxo_api_client/models/generated/hist/accountvalues.py +17 -0
- saxo_api_client/models/generated/hist/historicalpositions.py +111 -0
- saxo_api_client/models/generated/hist/performance.py +68 -0
- saxo_api_client/models/generated/hist/transactions.py +38 -0
- saxo_api_client/models/generated/hist/unsettledamounts.py +50 -0
- saxo_api_client/models/generated/mkt/instrumentdocument.py +117 -0
- saxo_api_client/models/generated/partnerintegration/advisoryaccounts.py +67 -0
- saxo_api_client/models/generated/partnerintegration/externalaccounts.py +58 -0
- saxo_api_client/models/generated/partnerintegration/fundinginstruction.py +42 -0
- saxo_api_client/models/generated/partnerintegration/interactiveesigning.py +39 -0
- saxo_api_client/models/generated/partnerintegration/interactiveidverification.py +29 -0
- saxo_api_client/models/generated/partnerintegration/partnerbulkbookings.py +22 -0
- saxo_api_client/models/generated/partnerintegration/updatepricing.py +104 -0
- saxo_api_client/models/generated/port/accountgroups.py +91 -0
- saxo_api_client/models/generated/port/accounts.py +335 -0
- saxo_api_client/models/generated/port/balances.py +494 -0
- saxo_api_client/models/generated/port/clients.py +254 -0
- saxo_api_client/models/generated/port/closedpositions.py +306 -0
- saxo_api_client/models/generated/port/exposure.py +285 -0
- saxo_api_client/models/generated/port/netpositions.py +485 -0
- saxo_api_client/models/generated/port/orders.py +615 -0
- saxo_api_client/models/generated/port/positions.py +548 -0
- saxo_api_client/models/generated/port/users.py +195 -0
- saxo_api_client/models/generated/ref/algostrategies.py +20 -0
- saxo_api_client/models/generated/ref/countries.py +26 -0
- saxo_api_client/models/generated/ref/cultures.py +23 -0
- saxo_api_client/models/generated/ref/currencies.py +25 -0
- saxo_api_client/models/generated/ref/currencypairs.py +17 -0
- saxo_api_client/models/generated/ref/exchanges.py +20 -0
- saxo_api_client/models/generated/ref/instruments.py +163 -0
- saxo_api_client/models/generated/ref/languages.py +24 -0
- saxo_api_client/models/generated/ref/standarddates.py +20 -0
- saxo_api_client/models/generated/ref/timezones.py +26 -0
- saxo_api_client/models/generated/reg/financialoverview.py +65 -0
- saxo_api_client/models/generated/reg/investmentprofile.py +76 -0
- saxo_api_client/models/generated/reg/knowledgeandexperience.py +25 -0
- saxo_api_client/models/generated/trade/allocationkeys.py +145 -0
- saxo_api_client/models/generated/trade/infoprices.py +172 -0
- saxo_api_client/models/generated/trade/messages.py +60 -0
- saxo_api_client/models/generated/trade/optionschain.py +125 -0
- saxo_api_client/models/generated/trade/orders.py +365 -0
- saxo_api_client/models/generated/trade/positions.py +115 -0
- saxo_api_client/models/generated/trade/prices.py +205 -0
- saxo_api_client/models/generated/trade/trades.py +131 -0
- saxo_api_client/models/generated/vas/pricealerts.py +165 -0
- saxo_api_client/models/openapi.py +5296 -0
- saxo_api_client/models/portfolio.py +86 -0
- saxo_api_client/trace.py +277 -0
- saxo_api_client/types/__init__.py +56 -0
- saxo_api_client/types/primitives.py +62 -0
- saxo_api_client/types/responses.py +106 -0
- saxo_api_client-1.0.0.dist-info/METADATA +232 -0
- saxo_api_client-1.0.0.dist-info/RECORD +196 -0
- saxo_api_client-1.0.0.dist-info/WHEEL +4 -0
- 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
|