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,507 @@
1
+ from .db_manager import db_manager
2
+ import pandas as pd
3
+ import duckdb
4
+ import os
5
+ from typing import List, Tuple, Optional, Dict
6
+ from datetime import datetime
7
+ import logging
8
+ from contextlib import contextmanager
9
+
10
+ logger = logging.getLogger(__name__)
11
+ logger.setLevel(logging.INFO)
12
+
13
+ class db_stocks_daily(db_manager):
14
+
15
+ def __init__(self):
16
+ super().__init__()
17
+
18
+
19
+ def _ensure_metadata_table(self, db: duckdb.DuckDBPyConnection) -> None:
20
+ """
21
+ メタデータテーブルが存在することを確認し、なければ作成する
22
+ """
23
+ table_name = "stocks_daily_metadata"
24
+ if not self._table_exists(db, table_name):
25
+ create_sql = f"""
26
+ CREATE TABLE {table_name} (
27
+ "Code" VARCHAR(20) PRIMARY KEY,
28
+ "from_date" DATE,
29
+ "to_date" DATE,
30
+ "record_count" INTEGER,
31
+ "last_updated" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
32
+ )
33
+ """
34
+ db.execute(create_sql)
35
+ logger.info(f"メタデータテーブル '{table_name}' を作成しました")
36
+
37
+
38
+ def _save_metadata(self, db: duckdb.DuckDBPyConnection, code: str, from_date: str, to_date: str, record_count: int) -> None:
39
+ """
40
+ 株価データの保存期間をメタデータテーブルに保存/更新
41
+
42
+ Args:
43
+ db: DuckDB接続
44
+ code: 銘柄コード
45
+ from_date: データ開始日 (YYYY-MM-DD形式)
46
+ to_date: データ終了日 (YYYY-MM-DD形式)
47
+ record_count: レコード数
48
+ """
49
+ self._ensure_metadata_table(db)
50
+
51
+ table_name = "stocks_daily_metadata"
52
+
53
+ # 既存のメタデータを取得
54
+ existing = db.execute(
55
+ f'SELECT "from_date", "to_date", "record_count" FROM {table_name} WHERE "Code" = ?',
56
+ [code]
57
+ ).fetchone()
58
+
59
+ if existing:
60
+ # 既存データがある場合は期間を拡張
61
+ old_from, old_to, old_count = existing
62
+ new_from = min(from_date, str(old_from)) if old_from else from_date
63
+ new_to = max(to_date, str(old_to)) if old_to else to_date
64
+
65
+ # 更新
66
+ db.execute(
67
+ f"""
68
+ UPDATE {table_name}
69
+ SET "from_date" = ?, "to_date" = ?, "record_count" = ?, "last_updated" = CURRENT_TIMESTAMP
70
+ WHERE "Code" = ?
71
+ """,
72
+ [new_from, new_to, record_count, code]
73
+ )
74
+ logger.info(f"メタデータを更新しました: {code} ({new_from} ~ {new_to}, {record_count}件)")
75
+ else:
76
+ # 新規挿入
77
+ db.execute(
78
+ f"""
79
+ INSERT INTO {table_name} ("Code", "from_date", "to_date", "record_count")
80
+ VALUES (?, ?, ?, ?)
81
+ """,
82
+ [code, from_date, to_date, record_count]
83
+ )
84
+ logger.info(f"メタデータを作成しました: {code} ({from_date} ~ {to_date}, {record_count}件)")
85
+
86
+
87
+ def _get_metadata(self, db: duckdb.DuckDBPyConnection, code: str) -> Optional[Dict]:
88
+ """
89
+ メタデータを取得
90
+
91
+ Returns:
92
+ メタデータの辞書、存在しない場合はNone
93
+ """
94
+ table_name = "stocks_daily_metadata"
95
+
96
+ if not self._table_exists(db, table_name):
97
+ return None
98
+
99
+ result = db.execute(
100
+ f'SELECT "Code", "from_date", "to_date", "record_count", "last_updated" FROM {table_name} WHERE "Code" = ?',
101
+ [code]
102
+ ).fetchone()
103
+
104
+ if result:
105
+ return {
106
+ 'code': result[0],
107
+ 'from_date': result[1],
108
+ 'to_date': result[2],
109
+ 'record_count': result[3],
110
+ 'last_updated': result[4]
111
+ }
112
+ return None
113
+
114
+
115
+ def _check_period_coverage(self, metadata: Optional[Dict], from_: Optional[datetime], to: Optional[datetime]) -> Dict:
116
+ """
117
+ 要求された期間が保存済み期間内かをチェック
118
+
119
+ Args:
120
+ metadata: メタデータ辞書
121
+ from_: 要求開始日
122
+ to: 要求終了日
123
+
124
+ Returns:
125
+ カバレッジ情報の辞書
126
+ """
127
+ if not metadata:
128
+ return {
129
+ 'is_covered': False,
130
+ 'message': 'データが保存されていません',
131
+ 'saved_from': None,
132
+ 'saved_to': None
133
+ }
134
+
135
+ saved_from = metadata['from_date']
136
+ saved_to = metadata['to_date']
137
+
138
+ # 日付をdate型に変換
139
+ if isinstance(saved_from, str):
140
+ saved_from = datetime.strptime(saved_from, '%Y-%m-%d').date()
141
+ if isinstance(saved_to, str):
142
+ saved_to = datetime.strptime(saved_to, '%Y-%m-%d').date()
143
+
144
+ # 要求された期間がない場合は全期間カバー済みと判定
145
+ if from_ is None and to is None:
146
+ return {
147
+ 'is_covered': True,
148
+ 'message': f'保存期間: {saved_from} ~ {saved_to}',
149
+ 'saved_from': saved_from,
150
+ 'saved_to': saved_to
151
+ }
152
+
153
+ # 要求された期間をチェック
154
+ request_from = from_.date() if from_ else saved_from
155
+ request_to = to.date() if to else saved_to
156
+
157
+ # 要求期間が保存済み期間内にあるかチェック
158
+ is_covered = (saved_from <= request_from) and (request_to <= saved_to)
159
+
160
+ if is_covered:
161
+ message = f'要求期間は保存済み ({saved_from} ~ {saved_to})'
162
+ else:
163
+ message = f'要求期間の一部または全部が未保存 (保存済み: {saved_from} ~ {saved_to}, 要求: {request_from} ~ {request_to})'
164
+
165
+ return {
166
+ 'is_covered': is_covered,
167
+ 'message': message,
168
+ 'saved_from': saved_from,
169
+ 'saved_to': saved_to,
170
+ 'request_from': request_from,
171
+ 'request_to': request_to
172
+ }
173
+
174
+
175
+ def save_stock_prices(self, code: str, df: pd.DataFrame, from_: datetime = None, to: datetime = None) -> None:
176
+ """
177
+ 株価時系列をDuckDBに保存(アップサート、動的テーブル作成対応)
178
+
179
+ Args:
180
+ code (str): 銘柄コード
181
+ df (pd.DataFrame): J-Quantsのカラムを想定(Date, Open, High, Low, Close, Volume)
182
+ from_ (datetime, optional): データ開始日(指定しない場合はdfから自動取得)
183
+ to (datetime, optional): データ終了日(指定しない場合はdfから自動取得)
184
+ """
185
+ try:
186
+ if not self.isEnable:
187
+ return
188
+
189
+ if df is None or df.empty:
190
+ logger.info("priceデータが空のため保存をスキップしました")
191
+ return
192
+
193
+ # 必須カラムの定義
194
+ required_columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume']
195
+
196
+ # Dateがインデックスになっている場合は、カラムとして追加
197
+ # Dateがカラムとして既に存在する場合は、インデックスを削除(drop=True)
198
+ if df.index.name == 'Date' or isinstance(df.index, pd.DatetimeIndex):
199
+ if 'Date' in df.columns:
200
+ # Dateがカラムとして存在する場合は、インデックスを削除
201
+ df = df.reset_index(drop=True)
202
+ else:
203
+ # Dateがカラムとして存在しない場合は、インデックスをカラムとして追加
204
+ df = df.reset_index()
205
+
206
+ # 必須カラムが存在するかチェック
207
+ missing_columns = [col for col in required_columns if col not in df.columns]
208
+ if missing_columns:
209
+ logger.warning(f"必須カラムが不足しています: {missing_columns}。保存をスキップします。")
210
+ return
211
+
212
+ # 必須カラムのみを選択(UpperLimit/LowerLimitなどの追加カラムを除外)
213
+ df_to_save = df[required_columns].copy()
214
+
215
+ # Codeカラムを追加(Codeカラムが存在する場合はリネーム、存在しない場合は追加)
216
+ if 'Code' in df.columns:
217
+ df_to_save['Code'] = df['Code'].iloc[0] if len(df) > 0 else code
218
+ elif 'Code' not in df_to_save.columns:
219
+ df_to_save['Code'] = code
220
+
221
+ # 同一日付の重複データを事前にフィルタリング(最新のデータを保持)
222
+ if 'Date' in df_to_save.columns:
223
+ # Dateをdatetime型に変換
224
+ df_to_save['Date'] = pd.to_datetime(df_to_save['Date'], errors='coerce')
225
+ # 無効な日付を除外
226
+ df_to_save = df_to_save.dropna(subset=['Date'])
227
+ if not df_to_save.empty:
228
+ # 同一日付のデータがある場合、最新のデータを保持(keep='last')
229
+ df_to_save = df_to_save.sort_values(by='Date', kind='mergesort')
230
+ df_to_save = df_to_save.drop_duplicates(subset=['Code', 'Date'], keep='last')
231
+
232
+ with self.get_db(code) as db:
233
+
234
+ # テーブル名
235
+ table_name = "stocks_daily"
236
+
237
+ # トランザクション開始
238
+ db.execute("BEGIN TRANSACTION")
239
+
240
+ try:
241
+
242
+ if self._table_exists(db, table_name):
243
+ logger.info(f"テーブル:{table_name} は、すでに存在しています。新規データをチェックします。")
244
+ # CodeとDateの組み合わせで重複チェック
245
+ existing_df = db.execute(
246
+ f'SELECT DISTINCT "Code", "Date" FROM {table_name}'
247
+ ).fetchdf()
248
+
249
+ if not existing_df.empty:
250
+ existing_df['Date'] = pd.to_datetime(existing_df['Date']).dt.strftime('%Y-%m-%d')
251
+ existing_df['Code'] = existing_df['Code'].astype(str)
252
+ existing_pairs = set(
253
+ [(str(row['Code']), str(row['Date'])) for _, row in existing_df.iterrows()]
254
+ )
255
+ else:
256
+ existing_pairs = set()
257
+
258
+ df_to_save_copy = df_to_save.copy()
259
+ if 'Date' in df_to_save_copy.columns:
260
+ df_to_save_copy['Date'] = pd.to_datetime(df_to_save_copy['Date']).dt.strftime('%Y-%m-%d')
261
+ if 'Code' in df_to_save_copy.columns:
262
+ df_to_save_copy['Code'] = df_to_save_copy['Code'].astype(str)
263
+
264
+ new_pairs = set(
265
+ [(str(row['Code']), str(row['Date'])) for _, row in df_to_save_copy.iterrows()]
266
+ )
267
+
268
+ unique_pairs = new_pairs - existing_pairs
269
+ if unique_pairs:
270
+ mask = df_to_save_copy.apply(
271
+ lambda row: (str(row['Code']), str(row['Date'])) in unique_pairs,
272
+ axis=1
273
+ )
274
+ new_data_df = df_to_save[mask].copy()
275
+ if 'Date' in new_data_df.columns:
276
+ new_data_df['Date'] = pd.to_datetime(new_data_df['Date']).dt.strftime('%Y-%m-%d')
277
+ if 'Code' in new_data_df.columns:
278
+ new_data_df['Code'] = new_data_df['Code'].astype(str)
279
+ logger.info(f"新規データ {len(new_data_df)} 件を追加します(銘柄コード: {code})")
280
+ self._batch_insert_data(db, table_name, new_data_df)
281
+ else:
282
+ logger.info(f"新規データはありません(銘柄コード: {code})")
283
+
284
+ else:
285
+ if not self._table_exists(db, table_name):
286
+ logger.info(f"新しいテーブル {table_name} を作成します")
287
+ df_to_save_normalized = df_to_save.copy()
288
+ if 'Date' in df_to_save_normalized.columns:
289
+ df_to_save_normalized['Date'] = pd.to_datetime(df_to_save_normalized['Date']).dt.strftime('%Y-%m-%d')
290
+ primary_keys = ['Code', 'Date'] if 'Code' in df_to_save_normalized.columns and 'Date' in df_to_save_normalized.columns else ['Date']
291
+ self._create_table_from_dataframe(db, table_name, df_to_save_normalized, primary_keys)
292
+ if 'Code' in df_to_save_normalized.columns:
293
+ db.execute(f'CREATE INDEX IF NOT EXISTS idx_{table_name}_Code ON {table_name}("Code")')
294
+ if 'Date' in df_to_save_normalized.columns:
295
+ db.execute(f'CREATE INDEX IF NOT EXISTS idx_{table_name}_Date ON {table_name}("Date")')
296
+ self._batch_insert_data(db, table_name, df_to_save_normalized)
297
+
298
+ # メタデータの保存
299
+ if 'Date' in df_to_save.columns:
300
+ date_stats = db.execute(
301
+ f'SELECT MIN("Date") as min_date, MAX("Date") as max_date, COUNT(*) as count FROM {table_name} WHERE "Code" = ?',
302
+ [code]
303
+ ).fetchone()
304
+
305
+ if date_stats and date_stats[0]:
306
+ actual_from = str(date_stats[0])
307
+ actual_to = str(date_stats[1])
308
+ actual_count = date_stats[2]
309
+
310
+ self._save_metadata(db, code, actual_from, actual_to, actual_count)
311
+
312
+ # トランザクションコミット
313
+ db.execute("COMMIT")
314
+ logger.info(f"priceデータをDuckDBに保存しました: 銘柄コード={code}, 件数={len(df_to_save)}")
315
+
316
+ except Exception as e:
317
+ db.execute("ROLLBACK")
318
+ raise e
319
+
320
+ except Exception as e:
321
+ logger.error(f"キャッシュの保存に失敗しました: {str(e)}", exc_info=True)
322
+ raise
323
+
324
+
325
+ def load_stock_prices_from_cache(self, code: str, from_: datetime = None, to: datetime = None) -> pd.DataFrame:
326
+ """
327
+ 株価時系列をDuckDBから取得
328
+
329
+ Args:
330
+ code (str): 銘柄コード
331
+ from_ (datetime, optional): 取得開始日
332
+ to (datetime, optional): 取得終了日
333
+
334
+ Returns:
335
+ pd.DataFrame: 株価データ
336
+ """
337
+ try:
338
+ if not self.isEnable:
339
+ return pd.DataFrame()
340
+
341
+ start_date = ""
342
+ end_date = ""
343
+ if not from_ is None:
344
+ if isinstance(from_, str):
345
+ from_ = datetime.strptime(from_, '%Y-%m-%d')
346
+ start_date = from_.strftime('%Y-%m-%d')
347
+ if not to is None:
348
+ if isinstance(to, str):
349
+ to = datetime.strptime(to, '%Y-%m-%d')
350
+ end_date = to.strftime('%Y-%m-%d')
351
+
352
+ table_name = "stocks_daily"
353
+
354
+ with self.get_db(code) as db:
355
+
356
+ if not self._table_exists(db, table_name):
357
+ logger.debug(f"キャッシュにデータがありません(外部APIから取得します): {code}")
358
+ return pd.DataFrame()
359
+
360
+ metadata = self._get_metadata(db, code)
361
+ if metadata:
362
+ coverage = self._check_period_coverage(metadata, from_, to)
363
+
364
+ logger.info(f"期間チェック: {code} - {coverage['message']}")
365
+
366
+ if not coverage['is_covered']:
367
+ logger.warning(f"要求期間が保存済み期間外です: {code}\n")
368
+ return pd.DataFrame()
369
+ else:
370
+ logger.info(f"メタデータが存在しません: {code}")
371
+ return pd.DataFrame()
372
+
373
+ params = []
374
+ cond_parts = []
375
+ cond_parts.append('"Code" = ?')
376
+ params.append(code)
377
+ if start_date:
378
+ cond_parts.append('"Date" >= ?')
379
+ params.append(start_date)
380
+ if end_date:
381
+ cond_parts.append('"Date" <= ?')
382
+ params.append(end_date)
383
+
384
+ where_clause = f"WHERE {' AND '.join(cond_parts)}" if cond_parts else ""
385
+ query = f'SELECT * FROM {table_name} {where_clause} ORDER BY "Date"'
386
+
387
+ df = db.execute(query, params).fetchdf()
388
+
389
+ # Date列をDatetimeIndexに設定
390
+ if not df.empty and 'Date' in df.columns:
391
+ df['Date'] = pd.to_datetime(df['Date'])
392
+ df = df.set_index('Date')
393
+
394
+ logger.info(f"株価データをDuckDBから読み込みました: {code} ({len(df)}件)")
395
+
396
+ return df
397
+
398
+ except Exception as e:
399
+ logger.error(f"キャッシュの読み込みに失敗しました: {str(e)}", exc_info=True)
400
+ return pd.DataFrame()
401
+
402
+
403
+ def ensure_db_ready(self, code: str) -> None:
404
+ """
405
+ DuckDBファイルの準備を行う(存在しなければFTPからダウンロードを試行)
406
+
407
+ Args:
408
+ code (str): 銘柄コード
409
+ """
410
+ if not self.isEnable:
411
+ return
412
+
413
+ # コードの正規化(サフィックス除去)
414
+ normalized_code = code
415
+ if len(code) > 4:
416
+ normalized_code = code[:-1]
417
+
418
+ db_path = os.path.join(self.cache_dir, "stocks_daily", f"{normalized_code}.duckdb")
419
+
420
+ if not os.path.exists(db_path):
421
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
422
+ # FTPからダウンロードを試行
423
+ if self._download_from_ftp(normalized_code, db_path):
424
+ logger.info(f"DuckDBファイルをFTPからダウンロードしました: {db_path}")
425
+ else:
426
+ logger.debug(f"FTPにDuckDBファイルが存在しません: {normalized_code}")
427
+
428
+
429
+ @contextmanager
430
+ def get_db(self, code: str):
431
+ """
432
+ DuckDBデータベース接続を取得
433
+
434
+ Args:
435
+ code (str): 銘柄コード
436
+
437
+ Yields:
438
+ duckdb.DuckDBPyConnection: DuckDB接続オブジェクト
439
+ """
440
+ db_path = os.path.join(self.cache_dir, "stocks_daily", f"{code}.duckdb")
441
+ if not os.path.exists(db_path):
442
+ if len(code) > 4:
443
+ code_retry = code[:-1]
444
+ # 再帰呼び出しの結果を返す(ジェネレータなので yield from)
445
+ yield from self.get_db(code_retry)
446
+ return
447
+
448
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
449
+
450
+ # FTPからダウンロードを試行
451
+ if self._download_from_ftp(code, db_path):
452
+ logger.info(f"DuckDBファイルをFTPからダウンロードしました: {db_path}")
453
+ else:
454
+ logger.info(f"DuckDBファイルを作成しました: {db_path}")
455
+
456
+ db = duckdb.connect(db_path)
457
+ try:
458
+ yield db
459
+ finally:
460
+ db.close()
461
+
462
+ def _download_from_ftp(self, code: str, local_path: str) -> bool:
463
+ """
464
+ FTPサーバーからDuckDBファイルをダウンロード
465
+ """
466
+ import ftplib
467
+
468
+ FTP_HOST = 'backcast.i234.me'
469
+ FTP_USER = 'sasaco_worker'
470
+ FTP_PASSWORD = 'S#1y9c%7o9'
471
+ FTP_PORT = 21
472
+ REMOTE_DIR = '/StockData/jp/stocks_daily'
473
+
474
+ try:
475
+ with ftplib.FTP() as ftp:
476
+ ftp.connect(FTP_HOST, FTP_PORT)
477
+ ftp.login(FTP_USER, FTP_PASSWORD)
478
+
479
+ remote_file = f"{REMOTE_DIR}/{code}.duckdb"
480
+
481
+ # ファイルサイズ確認(存在確認も兼ねる)
482
+ try:
483
+ ftp.voidcmd(f"TYPE I")
484
+ size = ftp.size(remote_file)
485
+ if size is None: # sizeコマンドがサポートされていない場合のフォールバックは省略
486
+ pass
487
+ except Exception:
488
+ logger.debug(f"FTPサーバーにファイルが見つかりません: {remote_file}")
489
+ return False
490
+
491
+ logger.info(f"FTPダウンロード開始: {remote_file} -> {local_path}")
492
+
493
+ with open(local_path, 'wb') as f:
494
+ ftp.retrbinary(f"RETR {remote_file}", f.write)
495
+
496
+ logger.info(f"FTPダウンロード完了: {local_path}")
497
+ return True
498
+
499
+ except Exception as e:
500
+ logger.warning(f"FTPダウンロード失敗: {e}")
501
+ # ダウンロード中の不完全なファイルが残っている場合は削除
502
+ if os.path.exists(local_path):
503
+ try:
504
+ os.remove(local_path)
505
+ except:
506
+ pass
507
+ return False