BackcastPro 0.3.4__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.
- BackcastPro/__init__.py +28 -0
- BackcastPro/_broker.py +430 -0
- BackcastPro/_stats.py +177 -0
- BackcastPro/api/__init__.py +4 -0
- BackcastPro/api/board.py +130 -0
- BackcastPro/api/chart.py +527 -0
- BackcastPro/api/db_manager.py +283 -0
- BackcastPro/api/db_stocks_board.py +428 -0
- BackcastPro/api/db_stocks_daily.py +507 -0
- BackcastPro/api/db_stocks_info.py +260 -0
- BackcastPro/api/lib/__init__.py +4 -0
- BackcastPro/api/lib/e_api.py +588 -0
- BackcastPro/api/lib/jquants.py +384 -0
- BackcastPro/api/lib/kabusap.py +222 -0
- BackcastPro/api/lib/stooq.py +409 -0
- BackcastPro/api/lib/util.py +38 -0
- BackcastPro/api/stocks_board.py +77 -0
- BackcastPro/api/stocks_info.py +88 -0
- BackcastPro/api/stocks_price.py +131 -0
- BackcastPro/backtest.py +594 -0
- BackcastPro/order.py +161 -0
- BackcastPro/position.py +60 -0
- BackcastPro/trade.py +227 -0
- backcastpro-0.3.4.dist-info/METADATA +112 -0
- backcastpro-0.3.4.dist-info/RECORD +26 -0
- backcastpro-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from requests import Response
|
|
4
|
+
import requests
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
# 環境変数を読み込み
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
load_dotenv()
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
logger.setLevel(logging.INFO)
|
|
19
|
+
|
|
20
|
+
class e_api:
|
|
21
|
+
"""
|
|
22
|
+
立花証券-e支店 API Client (Singleton)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_instance = None
|
|
26
|
+
_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def __new__(cls):
|
|
29
|
+
if cls._instance is None:
|
|
30
|
+
with cls._lock:
|
|
31
|
+
if cls._instance is None:
|
|
32
|
+
cls._instance = super(e_api, cls).__new__(cls)
|
|
33
|
+
return cls._instance
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
# 既に初期化済みの場合はスキップ
|
|
37
|
+
if hasattr(self, '_initialized'):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
self.API_URL = os.getenv('eAPI_URL')
|
|
41
|
+
self.p_no = 1
|
|
42
|
+
self.sUrlRequest = ""
|
|
43
|
+
self.sUrlMaster = ""
|
|
44
|
+
self.sUrlPrice = ""
|
|
45
|
+
self.sUrlEvent = ""
|
|
46
|
+
self.sUrlEventWebSocket = ""
|
|
47
|
+
|
|
48
|
+
self.token_expires_at = None
|
|
49
|
+
|
|
50
|
+
# キャッシュファイルのパス設定
|
|
51
|
+
env_cache_dir = os.environ.get('BACKCASTPRO_CACHE_DIR', tempfile.mkdtemp())
|
|
52
|
+
self.cache_dir = Path(os.path.abspath(env_cache_dir))
|
|
53
|
+
os.environ['BACKCASTPRO_CACHE_DIR'] = str(self.cache_dir)
|
|
54
|
+
self.cache_file = self.cache_dir / "e_api_login_cache.json"
|
|
55
|
+
self.failure_cache_file = self.cache_dir / "e_api_login_failures.json"
|
|
56
|
+
|
|
57
|
+
# ログイン失敗管理
|
|
58
|
+
self.login_failures = []
|
|
59
|
+
self.login_blocked_until = None
|
|
60
|
+
self.max_login_failures = int(os.getenv('eAPI_MAX_LOGIN_FAILURES', '3'))
|
|
61
|
+
|
|
62
|
+
self._initialized = True
|
|
63
|
+
|
|
64
|
+
# キャッシュから情報を読み込み
|
|
65
|
+
if self._load_from_cache():
|
|
66
|
+
self.isEnable = True
|
|
67
|
+
logger.info("キャッシュからログイン情報を読み込みました。")
|
|
68
|
+
else:
|
|
69
|
+
self.isEnable = self._set_token()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_from_cache(self) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
キャッシュファイルからログイン情報を読み込む
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
bool: 有効なキャッシュが読み込めた場合はTrue
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
if not self.cache_file.exists():
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
|
84
|
+
cache_data = json.load(f)
|
|
85
|
+
|
|
86
|
+
# 有効期限をチェック
|
|
87
|
+
token_expires_at = datetime.fromisoformat(cache_data.get('token_expires_at'))
|
|
88
|
+
if datetime.now() >= token_expires_at:
|
|
89
|
+
logger.info("キャッシュのトークンが期限切れです。")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# ログイン情報を復元
|
|
93
|
+
self.sUrlRequest = cache_data.get('sUrlRequest', '')
|
|
94
|
+
self.sUrlMaster = cache_data.get('sUrlMaster', '')
|
|
95
|
+
self.sUrlPrice = cache_data.get('sUrlPrice', '')
|
|
96
|
+
self.sUrlEvent = cache_data.get('sUrlEvent', '')
|
|
97
|
+
self.sUrlEventWebSocket = cache_data.get('sUrlEventWebSocket', '')
|
|
98
|
+
self.p_no = cache_data.get('p_no', 1)
|
|
99
|
+
self.token_expires_at = token_expires_at
|
|
100
|
+
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"キャッシュの読み込みに失敗しました: {e}")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def _save_to_cache(self) -> None:
|
|
108
|
+
"""
|
|
109
|
+
ログイン情報をキャッシュファイルに保存
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
if self.token_expires_at is None:
|
|
113
|
+
logger.warning("token_expires_atがNullのため、キャッシュに保存できません。")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# キャッシュディレクトリが存在しない場合は作成
|
|
117
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
cache_data = {
|
|
120
|
+
'sUrlRequest': self.sUrlRequest,
|
|
121
|
+
'sUrlMaster': self.sUrlMaster,
|
|
122
|
+
'sUrlPrice': self.sUrlPrice,
|
|
123
|
+
'sUrlEvent': self.sUrlEvent,
|
|
124
|
+
'sUrlEventWebSocket': self.sUrlEventWebSocket,
|
|
125
|
+
'p_no': self.p_no,
|
|
126
|
+
'token_expires_at': self.token_expires_at.isoformat(),
|
|
127
|
+
'saved_at': datetime.now().isoformat()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
|
131
|
+
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
|
132
|
+
|
|
133
|
+
logger.info(f"ログイン情報をキャッシュに保存しました: {self.cache_file}")
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"キャッシュの保存に失敗しました: {e}")
|
|
137
|
+
|
|
138
|
+
def _load_login_failures(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
ログイン失敗履歴を読み込む
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
if not self.failure_cache_file.exists():
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
with open(self.failure_cache_file, 'r', encoding='utf-8') as f:
|
|
147
|
+
failure_data = json.load(f)
|
|
148
|
+
|
|
149
|
+
self.login_failures = [datetime.fromisoformat(dt) for dt in failure_data.get('failures', [])]
|
|
150
|
+
if failure_data.get('blocked_until'):
|
|
151
|
+
self.login_blocked_until = datetime.fromisoformat(failure_data.get('blocked_until'))
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.warning(f"ログイン失敗履歴の読み込みに失敗しました: {e}")
|
|
155
|
+
|
|
156
|
+
def _save_login_failures(self) -> None:
|
|
157
|
+
"""
|
|
158
|
+
ログイン失敗履歴を保存
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
# キャッシュディレクトリが存在しない場合は作成
|
|
162
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
failure_data = {
|
|
165
|
+
'failures': [dt.isoformat() for dt in self.login_failures],
|
|
166
|
+
'blocked_until': self.login_blocked_until.isoformat() if self.login_blocked_until else None
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
with open(self.failure_cache_file, 'w', encoding='utf-8') as f:
|
|
170
|
+
json.dump(failure_data, f, ensure_ascii=False, indent=2)
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error(f"ログイン失敗履歴の保存に失敗しました: {e}")
|
|
174
|
+
|
|
175
|
+
def _record_login_failure(self) -> None:
|
|
176
|
+
"""
|
|
177
|
+
ログイン失敗を記録し、必要に応じてブロックを設定
|
|
178
|
+
"""
|
|
179
|
+
now = datetime.now()
|
|
180
|
+
|
|
181
|
+
# 24時間以内の失敗のみを保持
|
|
182
|
+
twenty_four_hours_ago = now - timedelta(hours=24)
|
|
183
|
+
self.login_failures = [dt for dt in self.login_failures if dt > twenty_four_hours_ago]
|
|
184
|
+
|
|
185
|
+
# 新しい失敗を記録
|
|
186
|
+
self.login_failures.append(now)
|
|
187
|
+
|
|
188
|
+
# 24時間以内に指定回数失敗した場合、24時間ブロック
|
|
189
|
+
if len(self.login_failures) >= self.max_login_failures:
|
|
190
|
+
self.login_blocked_until = now + timedelta(hours=24)
|
|
191
|
+
logger.warning(f"24時間以内に{self.max_login_failures}回ログインに失敗しました。{self.login_blocked_until}までログインをブロックします。")
|
|
192
|
+
|
|
193
|
+
self._save_login_failures()
|
|
194
|
+
|
|
195
|
+
def _is_login_blocked(self) -> bool:
|
|
196
|
+
"""
|
|
197
|
+
ログインがブロックされているかチェック
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
bool: ブロックされている場合はTrue
|
|
201
|
+
"""
|
|
202
|
+
self._load_login_failures()
|
|
203
|
+
|
|
204
|
+
if self.login_blocked_until:
|
|
205
|
+
if datetime.now() < self.login_blocked_until:
|
|
206
|
+
logger.warning(f"ログインがブロックされています。ブロック解除: {self.login_blocked_until}")
|
|
207
|
+
return True
|
|
208
|
+
else:
|
|
209
|
+
# ブロック期間が過ぎたらリセット
|
|
210
|
+
self.login_blocked_until = None
|
|
211
|
+
self.login_failures = []
|
|
212
|
+
self._save_login_failures()
|
|
213
|
+
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def _set_token(self) -> bool:
|
|
217
|
+
"""
|
|
218
|
+
仮想URLを取得
|
|
219
|
+
|
|
220
|
+
正しく設定ファイルが作成されていれば、本コードを実行することで、仮想URLを取得することができます。
|
|
221
|
+
「APIを使用する準備が完了しました。」と出力されれば、立花証券 APIをコールすることができるようになります!
|
|
222
|
+
"""
|
|
223
|
+
# ログインがブロックされているかチェック
|
|
224
|
+
if self._is_login_blocked():
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
# 仮想URLを取得
|
|
228
|
+
USER_DATA = {
|
|
229
|
+
"sCLMID":"CLMAuthLoginRequest",
|
|
230
|
+
"p_no" : str(self.p_no),
|
|
231
|
+
"p_sd_date": datetime.now().strftime('%Y.%m.%d-%H:%M:%S.%f')[:-3],
|
|
232
|
+
"sPassword":os.getenv('eAPI_PASSWORD'),
|
|
233
|
+
"sUserId":os.getenv('eAPI_USER_ID'),
|
|
234
|
+
"sJsonOfmt": "5"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# 環境変数が設定されていない場合はAPI呼び出しを行わない
|
|
238
|
+
if not USER_DATA["sUserId"] or not USER_DATA["sPassword"]:
|
|
239
|
+
logger.warning("立花証券-e支店の認証情報が設定されていません。")
|
|
240
|
+
return False
|
|
241
|
+
# refresh token取得
|
|
242
|
+
try:
|
|
243
|
+
url = f"{self.API_URL.rstrip('/')}/auth/?{json.dumps(USER_DATA)}"
|
|
244
|
+
req = requests.get(url)
|
|
245
|
+
str_api_response = req.content.decode(req.apparent_encoding, errors="ignore")
|
|
246
|
+
dic_return = json.loads(str_api_response)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"立花証券-e支店のlogin用のAPI問合せに失敗しました: {e}")
|
|
249
|
+
self._record_login_failure()
|
|
250
|
+
else:
|
|
251
|
+
# ログインの判定とログイン情報の保存
|
|
252
|
+
try:
|
|
253
|
+
int_p_errno = int(dic_return.get('p_errno', 0))
|
|
254
|
+
int_sResultCode = int(dic_return.get('sResultCode', 0))
|
|
255
|
+
|
|
256
|
+
if int_p_errno == 0 and int_sResultCode == 0: # ログインエラーでない場合
|
|
257
|
+
# 仮想URLを保存する。
|
|
258
|
+
self.sUrlRequest = dic_return.get('sUrlRequest')
|
|
259
|
+
self.sUrlMaster = dic_return.get('sUrlMaster')
|
|
260
|
+
self.sUrlPrice = dic_return.get('sUrlPrice')
|
|
261
|
+
self.sUrlEvent = dic_return.get('sUrlEvent')
|
|
262
|
+
self.sUrlEventWebSocket = dic_return.get('sUrlEventWebSocket')
|
|
263
|
+
|
|
264
|
+
# "p_no"を保存する。
|
|
265
|
+
self.p_no = int(dic_return.get('p_no'))
|
|
266
|
+
|
|
267
|
+
# トークンの有効期限を設定(24時間後)
|
|
268
|
+
p_sd_date = dic_return.get('p_sd_date')
|
|
269
|
+
## p_sd_dateを文字列からdatetime型に変換(フォーマット: "2025.10.09-07:32:59.888")
|
|
270
|
+
token_datetime = datetime.strptime(p_sd_date, "%Y.%m.%d-%H:%M:%S.%f")
|
|
271
|
+
self.token_expires_at = token_datetime + timedelta(hours=24)
|
|
272
|
+
|
|
273
|
+
# ログイン情報をキャッシュに保存
|
|
274
|
+
self._save_to_cache()
|
|
275
|
+
|
|
276
|
+
# ログイン失敗履歴をリセット
|
|
277
|
+
self.login_failures = []
|
|
278
|
+
self.login_blocked_until = None
|
|
279
|
+
if self.failure_cache_file.exists():
|
|
280
|
+
self.failure_cache_file.unlink()
|
|
281
|
+
|
|
282
|
+
logger.info("立花証券-e支店のAPI使用の準備が完了しました。")
|
|
283
|
+
return True
|
|
284
|
+
else :
|
|
285
|
+
logger.error(f"{int_sResultCode}: \n {dic_return.get('689')}")
|
|
286
|
+
self._record_login_failure()
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"立花証券-e支店のログイン情報の保存の取得に失敗しました: {e}")
|
|
289
|
+
self._record_login_failure()
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
def _refresh_token_if_needed(self) -> bool:
|
|
293
|
+
"""
|
|
294
|
+
トークンが期限切れの場合はリフレッシュする
|
|
295
|
+
"""
|
|
296
|
+
if self.token_expires_at:
|
|
297
|
+
# token_expires_atがfloatの場合はdatetimeに変換
|
|
298
|
+
if isinstance(self.token_expires_at, (int, float)):
|
|
299
|
+
self.token_expires_at = datetime.fromtimestamp(self.token_expires_at)
|
|
300
|
+
if datetime.now() >= self.token_expires_at:
|
|
301
|
+
logger.info("トークンの期限が切れているため、リフレッシュします。")
|
|
302
|
+
try:
|
|
303
|
+
# キャッシュを削除
|
|
304
|
+
if self.cache_file.exists():
|
|
305
|
+
self.cache_file.unlink()
|
|
306
|
+
|
|
307
|
+
self.isEnable = self._set_token()
|
|
308
|
+
if self.isEnable:
|
|
309
|
+
logger.info("トークンのリフレッシュが完了しました。")
|
|
310
|
+
return self.isEnable
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.error(f"トークンのリフレッシュに失敗しました: {e}")
|
|
313
|
+
return False
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_daily_quotes(self, code: str, from_: datetime = None, to: datetime = None) -> pd.DataFrame:
|
|
318
|
+
"""
|
|
319
|
+
株価四本値(/price-kabuka.e-shiten.jp/e_api_v4r8/price/)
|
|
320
|
+
|
|
321
|
+
備考:
|
|
322
|
+
銘柄コードは、通常銘柄、4桁。優先株等、5桁。
|
|
323
|
+
例、伊藤園'2593'、伊藤園優先株'25935'
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
# from_が20年より前の場合はスキップ(立花証券APIは最大約20年分のデータを保持)
|
|
327
|
+
if from_ is not None:
|
|
328
|
+
twenty_years_ago = datetime.now() - timedelta(days=365*20)
|
|
329
|
+
if from_ < twenty_years_ago:
|
|
330
|
+
logger.info(f"from_パラメータ({from_})が20年より前のため、スキップします。")
|
|
331
|
+
return pd.DataFrame()
|
|
332
|
+
|
|
333
|
+
# トークンリフレッシュが必要かチェック
|
|
334
|
+
self._refresh_token_if_needed()
|
|
335
|
+
|
|
336
|
+
params = {
|
|
337
|
+
"p_no" : str(self.p_no + 1),
|
|
338
|
+
"p_sd_date": datetime.now().strftime('%Y.%m.%d-%H:%M:%S.%f')[:-3],
|
|
339
|
+
"sCLMID":"CLMMfdsGetMarketPriceHistory",
|
|
340
|
+
"sIssueCode":str(code),
|
|
341
|
+
"sSizyouC":"00", # 市場(現在、東証'00'のみ)
|
|
342
|
+
"sJsonOfmt":"5" # 返り値の表示形式指定
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
res = requests.get(f"{self.sUrlPrice.rstrip('/')}/?{json.dumps(params)}")
|
|
346
|
+
if res.status_code == 200:
|
|
347
|
+
dic_return = res.json()
|
|
348
|
+
|
|
349
|
+
# エラーコードを取得
|
|
350
|
+
p_errno = int(dic_return.get('p_errno', 0))
|
|
351
|
+
# エラーが有っても無くても p_noを更新
|
|
352
|
+
self.p_no = int(dic_return.get('p_no', self.p_no))
|
|
353
|
+
self._save_to_cache()
|
|
354
|
+
|
|
355
|
+
if p_errno == 6:
|
|
356
|
+
logger.error(f"API Error: {dic_return.get('p_err')}")
|
|
357
|
+
# キャッシュに新しいp_noを保存
|
|
358
|
+
# retry with new p_no
|
|
359
|
+
return self.get_daily_quotes(code, from_, to)
|
|
360
|
+
|
|
361
|
+
if p_errno != 0:
|
|
362
|
+
logger.error(f"API Error: {p_errno} - {dic_return.get('p_err')}")
|
|
363
|
+
return pd.DataFrame()
|
|
364
|
+
|
|
365
|
+
if 'aCLMMfdsMarketPriceHistory' not in dic_return:
|
|
366
|
+
if len(code) > 4:
|
|
367
|
+
## codeが存在しないことが多い
|
|
368
|
+
code = code[:-1]
|
|
369
|
+
return self.get_daily_quotes(code, from_, to)
|
|
370
|
+
logger.error(f"API Error: {p_errno} - {dic_return.get('p_err')}")
|
|
371
|
+
return pd.DataFrame()
|
|
372
|
+
|
|
373
|
+
data = dic_return['aCLMMfdsMarketPriceHistory']
|
|
374
|
+
|
|
375
|
+
df = pd.DataFrame(data)
|
|
376
|
+
|
|
377
|
+
# カラム名を統一(J-Quants APIと合わせる)
|
|
378
|
+
df = _e_normalize_columns(code, df)
|
|
379
|
+
|
|
380
|
+
# from_とtoの期間でフィルタリング
|
|
381
|
+
# Dateはインデックスとして設定されているため、インデックスでフィルタリング
|
|
382
|
+
if from_ is not None:
|
|
383
|
+
if isinstance(df.index, pd.DatetimeIndex):
|
|
384
|
+
df = df[df.index >= from_]
|
|
385
|
+
elif 'Date' in df.columns:
|
|
386
|
+
df = df[df['Date'] >= from_]
|
|
387
|
+
if to is not None:
|
|
388
|
+
if isinstance(df.index, pd.DatetimeIndex):
|
|
389
|
+
df = df[df.index <= to]
|
|
390
|
+
elif 'Date' in df.columns:
|
|
391
|
+
df = df[df['Date'] <= to]
|
|
392
|
+
|
|
393
|
+
df['source'] = 'e-shiten'
|
|
394
|
+
|
|
395
|
+
return df
|
|
396
|
+
|
|
397
|
+
logger.error(f"API Error: {res.status_code} - {res.json()}")
|
|
398
|
+
return pd.DataFrame()
|
|
399
|
+
|
|
400
|
+
def get_board(self, code: str) -> pd.DataFrame:
|
|
401
|
+
"""
|
|
402
|
+
板情報を取得する
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
code (str): 銘柄コード
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
pd.DataFrame: 板情報
|
|
409
|
+
"""
|
|
410
|
+
# トークンリフレッシュが必要かチェック
|
|
411
|
+
self._refresh_token_if_needed()
|
|
412
|
+
|
|
413
|
+
# 銘柄コードの検証
|
|
414
|
+
if not code or not isinstance(code, str):
|
|
415
|
+
logger.error("銘柄コードが指定されていません。")
|
|
416
|
+
return pd.DataFrame()
|
|
417
|
+
code = str(code).strip()
|
|
418
|
+
if not code:
|
|
419
|
+
logger.error("銘柄コードが空です。")
|
|
420
|
+
return pd.DataFrame()
|
|
421
|
+
|
|
422
|
+
# 板情報コードを構築
|
|
423
|
+
board_columns = []
|
|
424
|
+
# 買い板(1-10段)
|
|
425
|
+
for i in range(1, 11):
|
|
426
|
+
board_columns.extend([f'pGBP{i}', f'pGBV{i}'])
|
|
427
|
+
board_columns.append('pQUV') # 買-UNDER
|
|
428
|
+
# 売り板(1-10段)
|
|
429
|
+
for i in range(1, 11):
|
|
430
|
+
board_columns.extend([f'pGAP{i}', f'pGAV{i}'])
|
|
431
|
+
board_columns.append('pQOV') # 売-OVER
|
|
432
|
+
sTargetColumn = ','.join(board_columns)
|
|
433
|
+
|
|
434
|
+
# APIパラメータの構築
|
|
435
|
+
params = {
|
|
436
|
+
"p_no": str(self.p_no + 1),
|
|
437
|
+
"p_sd_date": datetime.now().strftime('%Y.%m.%d-%H:%M:%S.%f')[:-3],
|
|
438
|
+
"sCLMID": "CLMMfdsGetMarketPrice",
|
|
439
|
+
"sTargetIssueCode": code,
|
|
440
|
+
"sTargetColumn": sTargetColumn,
|
|
441
|
+
"sJsonOfmt": "5"
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# API呼び出し
|
|
445
|
+
res = requests.get(f"{self.sUrlPrice.rstrip('/')}/?{json.dumps(params)}")
|
|
446
|
+
if res.status_code == 200:
|
|
447
|
+
dic_return = res.json()
|
|
448
|
+
|
|
449
|
+
# エラーチェック
|
|
450
|
+
p_errno = int(dic_return.get('p_errno', 0))
|
|
451
|
+
# エラーが有っても無くても p_noを更新
|
|
452
|
+
self.p_no = int(dic_return.get('p_no', self.p_no))
|
|
453
|
+
self._save_to_cache()
|
|
454
|
+
|
|
455
|
+
if p_errno == 6:
|
|
456
|
+
logger.error(f"API Error: {dic_return.get('p_err')}")
|
|
457
|
+
# リトライ
|
|
458
|
+
return self.get_board(code)
|
|
459
|
+
|
|
460
|
+
if p_errno != 0:
|
|
461
|
+
logger.error(f"API Error: {p_errno} - {dic_return.get('p_err')}")
|
|
462
|
+
return pd.DataFrame()
|
|
463
|
+
|
|
464
|
+
if 'aCLMMfdsMarketPrice' not in dic_return:
|
|
465
|
+
logger.error(f"API Error: aCLMMfdsMarketPrice not found in response")
|
|
466
|
+
return pd.DataFrame()
|
|
467
|
+
|
|
468
|
+
# レスポンスから板情報を抽出
|
|
469
|
+
board_data = []
|
|
470
|
+
response_data = dic_return.get('aCLMMfdsMarketPrice', [])
|
|
471
|
+
|
|
472
|
+
if response_data:
|
|
473
|
+
item = response_data[0] # 1銘柄のみ取得する想定
|
|
474
|
+
|
|
475
|
+
# 買い板(1-10段)
|
|
476
|
+
for i in range(1, 11):
|
|
477
|
+
price_key = f'pGBP{i}'
|
|
478
|
+
qty_key = f'pGBV{i}'
|
|
479
|
+
if price_key in item and qty_key in item:
|
|
480
|
+
price = item.get(price_key)
|
|
481
|
+
qty = item.get(qty_key)
|
|
482
|
+
if price and qty: # 値が存在する場合のみ追加
|
|
483
|
+
try:
|
|
484
|
+
board_data.append({
|
|
485
|
+
'Price': float(price) if price else 0,
|
|
486
|
+
'Qty': int(qty) if qty else 0,
|
|
487
|
+
'Type': 'Bid'
|
|
488
|
+
})
|
|
489
|
+
except (ValueError, TypeError):
|
|
490
|
+
# 数値変換エラーはスキップ
|
|
491
|
+
continue
|
|
492
|
+
|
|
493
|
+
# 買-UNDER
|
|
494
|
+
if 'pQUV' in item and item.get('pQUV'):
|
|
495
|
+
try:
|
|
496
|
+
board_data.append({
|
|
497
|
+
'Price': 0, # UNDERは値段なし
|
|
498
|
+
'Qty': int(item.get('pQUV')),
|
|
499
|
+
'Type': 'Bid'
|
|
500
|
+
})
|
|
501
|
+
except (ValueError, TypeError):
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
# 売り板(1-10段)
|
|
505
|
+
for i in range(1, 11):
|
|
506
|
+
price_key = f'pGAP{i}'
|
|
507
|
+
qty_key = f'pGAV{i}'
|
|
508
|
+
if price_key in item and qty_key in item:
|
|
509
|
+
price = item.get(price_key)
|
|
510
|
+
qty = item.get(qty_key)
|
|
511
|
+
if price and qty:
|
|
512
|
+
try:
|
|
513
|
+
board_data.append({
|
|
514
|
+
'Price': float(price) if price else 0,
|
|
515
|
+
'Qty': int(qty) if qty else 0,
|
|
516
|
+
'Type': 'Ask'
|
|
517
|
+
})
|
|
518
|
+
except (ValueError, TypeError):
|
|
519
|
+
# 数値変換エラーはスキップ
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
# 売-OVER
|
|
523
|
+
if 'pQOV' in item and item.get('pQOV'):
|
|
524
|
+
try:
|
|
525
|
+
board_data.append({
|
|
526
|
+
'Price': 0, # OVERは値段なし
|
|
527
|
+
'Qty': int(item.get('pQOV')),
|
|
528
|
+
'Type': 'Ask'
|
|
529
|
+
})
|
|
530
|
+
except (ValueError, TypeError):
|
|
531
|
+
pass
|
|
532
|
+
|
|
533
|
+
# DataFrame変換
|
|
534
|
+
if board_data:
|
|
535
|
+
df = pd.DataFrame(board_data)
|
|
536
|
+
df['source'] = 'e-shiten'
|
|
537
|
+
df['code'] = code
|
|
538
|
+
return df
|
|
539
|
+
else:
|
|
540
|
+
return pd.DataFrame()
|
|
541
|
+
|
|
542
|
+
logger.error(f"API Error: {res.status_code} - {res.text}")
|
|
543
|
+
return pd.DataFrame()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _e_normalize_columns(code: str, df: pd.DataFrame) -> pd.DataFrame:
|
|
547
|
+
"""
|
|
548
|
+
カラム名をJ-Quants APIの形式に統一する
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
df (pd.DataFrame): 元のDataFrame
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
pd.DataFrame: カラム名を統一したDataFrame
|
|
555
|
+
"""
|
|
556
|
+
# 立花証券 apiのカラム名をJ-Quantsの形式にマッピング
|
|
557
|
+
names_mapping = {
|
|
558
|
+
'pDOP': 'Open',
|
|
559
|
+
'pDHP': 'High',
|
|
560
|
+
'pDLP': 'Low',
|
|
561
|
+
'pDPP': 'Close',
|
|
562
|
+
'pDV': 'Volume',
|
|
563
|
+
"pDOPxK": "AdjustmentOpen",
|
|
564
|
+
"pDHPxK": "AdjustmentHigh",
|
|
565
|
+
"pDLPxK": "AdjustmentLow",
|
|
566
|
+
"pDPPxK": "AdjustmentClose",
|
|
567
|
+
"pDVxK": "AdjustmentVolume",
|
|
568
|
+
"pSPUK": "AdjustmentFactor"
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
df['Date'] = pd.to_datetime(df['sDate'], format='%Y%m%d')
|
|
572
|
+
|
|
573
|
+
# Dateをインデックスに設定
|
|
574
|
+
df = df.set_index('Date')
|
|
575
|
+
|
|
576
|
+
from .stooq import _common_normalize_columns
|
|
577
|
+
|
|
578
|
+
return _common_normalize_columns(code, df, names_mapping)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == '__main__':
|
|
583
|
+
"""
|
|
584
|
+
テスト用のメイン関数。このファイルを直接実行した場合に実行される。
|
|
585
|
+
"""
|
|
586
|
+
e_api = e_api()
|
|
587
|
+
df = e_api.get_board('8306')
|
|
588
|
+
print(df)
|