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.
@@ -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)