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,428 @@
|
|
|
1
|
+
from .db_manager import db_manager
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import duckdb
|
|
4
|
+
import os
|
|
5
|
+
from typing import 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_board(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_board_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_timestamp" TIMESTAMP,
|
|
29
|
+
"to_timestamp" TIMESTAMP,
|
|
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_timestamp: str, to_timestamp: str, record_count: int) -> None:
|
|
39
|
+
"""
|
|
40
|
+
板情報の保存期間をメタデータテーブルに保存/更新
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
db: DuckDB接続
|
|
44
|
+
code: 銘柄コード
|
|
45
|
+
from_timestamp: データ開始時刻 (YYYY-MM-DD HH:MM:SS形式)
|
|
46
|
+
to_timestamp: データ終了時刻 (YYYY-MM-DD HH:MM:SS形式)
|
|
47
|
+
record_count: レコード数
|
|
48
|
+
"""
|
|
49
|
+
self._ensure_metadata_table(db)
|
|
50
|
+
|
|
51
|
+
table_name = "stocks_board_metadata"
|
|
52
|
+
|
|
53
|
+
# 既存のメタデータを取得
|
|
54
|
+
existing = db.execute(
|
|
55
|
+
f'SELECT "from_timestamp", "to_timestamp", "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_timestamp, str(old_from)) if old_from else from_timestamp
|
|
63
|
+
new_to = max(to_timestamp, str(old_to)) if old_to else to_timestamp
|
|
64
|
+
|
|
65
|
+
# 更新
|
|
66
|
+
db.execute(
|
|
67
|
+
f"""
|
|
68
|
+
UPDATE {table_name}
|
|
69
|
+
SET "from_timestamp" = ?, "to_timestamp" = ?, "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_timestamp", "to_timestamp", "record_count")
|
|
80
|
+
VALUES (?, ?, ?, ?)
|
|
81
|
+
""",
|
|
82
|
+
[code, from_timestamp, to_timestamp, record_count]
|
|
83
|
+
)
|
|
84
|
+
logger.info(f"メタデータを作成しました: {code} ({from_timestamp} ~ {to_timestamp}, {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_board_metadata"
|
|
95
|
+
|
|
96
|
+
if not self._table_exists(db, table_name):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
result = db.execute(
|
|
100
|
+
f'SELECT "Code", "from_timestamp", "to_timestamp", "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_timestamp': result[1],
|
|
108
|
+
'to_timestamp': result[2],
|
|
109
|
+
'record_count': result[3],
|
|
110
|
+
'last_updated': result[4]
|
|
111
|
+
}
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def save_stock_board(self, code: str, df: pd.DataFrame) -> None:
|
|
116
|
+
"""
|
|
117
|
+
板情報をDuckDBに保存(アップサート、動的テーブル作成対応)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
code (str): 銘柄コード
|
|
121
|
+
df (pd.DataFrame): 板情報のDataFrame(Timestamp列を含む)
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
if not self.isEnable:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if df is None or df.empty:
|
|
128
|
+
logger.info("板情報データが空のため保存をスキップしました")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Timestampカラムの確認と処理
|
|
132
|
+
# まず、Timestampがインデックスになっている場合はカラムとして追加
|
|
133
|
+
if df.index.name == 'Timestamp' or isinstance(df.index, pd.DatetimeIndex):
|
|
134
|
+
if 'Timestamp' not in df.columns:
|
|
135
|
+
df = df.reset_index()
|
|
136
|
+
logger.info("TimestampインデックスをカラムとしてDataFrameに追加しました")
|
|
137
|
+
else:
|
|
138
|
+
df = df.reset_index(drop=True)
|
|
139
|
+
|
|
140
|
+
# Timestampカラムがない場合は現在時刻を追加
|
|
141
|
+
if 'Timestamp' not in df.columns:
|
|
142
|
+
df = df.copy()
|
|
143
|
+
df['Timestamp'] = datetime.now()
|
|
144
|
+
logger.info("Timestampカラムを追加しました")
|
|
145
|
+
|
|
146
|
+
# この時点でdfのコピーを作成してSettingWithCopyWarningを回避
|
|
147
|
+
df = df.copy()
|
|
148
|
+
|
|
149
|
+
# Codeカラムを追加(存在しない場合)
|
|
150
|
+
# 小文字のcodeカラムが存在する場合は大文字のCodeにリネーム(APIからのデータに対応)
|
|
151
|
+
if 'code' in df.columns and 'Code' not in df.columns:
|
|
152
|
+
df = df.rename(columns={'code': 'Code'})
|
|
153
|
+
logger.info("小文字の'code'カラムを大文字の'Code'にリネームしました")
|
|
154
|
+
elif 'Code' not in df.columns:
|
|
155
|
+
df['Code'] = code
|
|
156
|
+
|
|
157
|
+
# Timestampをdatetime型に変換
|
|
158
|
+
df['Timestamp'] = pd.to_datetime(df['Timestamp'], errors='coerce')
|
|
159
|
+
# 無効な日時を除外
|
|
160
|
+
df = df.dropna(subset=['Timestamp'])
|
|
161
|
+
|
|
162
|
+
if df.empty:
|
|
163
|
+
logger.warning("有効なTimestampがないため保存をスキップしました")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# 同一タイムスタンプの重複データを事前にフィルタリング(最新のデータを保持)
|
|
167
|
+
df = df.sort_values(by='Timestamp', kind='mergesort')
|
|
168
|
+
df = df.drop_duplicates(subset=['Code', 'Timestamp'], keep='last')
|
|
169
|
+
|
|
170
|
+
with self.get_db(code) as db:
|
|
171
|
+
|
|
172
|
+
# テーブル名
|
|
173
|
+
table_name = "stocks_board"
|
|
174
|
+
|
|
175
|
+
# トランザクション開始
|
|
176
|
+
db.execute("BEGIN TRANSACTION")
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
|
|
180
|
+
if self._table_exists(db, table_name):
|
|
181
|
+
logger.info(f"テーブル:{table_name} は、すでに存在しています。新規データをチェックします。")
|
|
182
|
+
# CodeとTimestampの組み合わせで重複チェック
|
|
183
|
+
existing_df = db.execute(
|
|
184
|
+
f'SELECT DISTINCT "Code", "Timestamp" FROM {table_name}'
|
|
185
|
+
).fetchdf()
|
|
186
|
+
|
|
187
|
+
if not existing_df.empty:
|
|
188
|
+
existing_df['Timestamp'] = pd.to_datetime(existing_df['Timestamp'], format='mixed').dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
189
|
+
existing_df['Code'] = existing_df['Code'].astype(str)
|
|
190
|
+
existing_pairs = set(
|
|
191
|
+
[(str(row['Code']), str(row['Timestamp'])) for _, row in existing_df.iterrows()]
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
existing_pairs = set()
|
|
195
|
+
|
|
196
|
+
df_to_save_copy = df.copy()
|
|
197
|
+
df_to_save_copy['Timestamp'] = pd.to_datetime(df_to_save_copy['Timestamp'], format='mixed').dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
198
|
+
df_to_save_copy['Code'] = df_to_save_copy['Code'].astype(str)
|
|
199
|
+
|
|
200
|
+
new_pairs = set(
|
|
201
|
+
[(str(row['Code']), str(row['Timestamp'])) for _, row in df_to_save_copy.iterrows()]
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
unique_pairs = new_pairs - existing_pairs
|
|
205
|
+
if unique_pairs:
|
|
206
|
+
mask = df_to_save_copy.apply(
|
|
207
|
+
lambda row: (str(row['Code']), str(row['Timestamp'])) in unique_pairs,
|
|
208
|
+
axis=1
|
|
209
|
+
)
|
|
210
|
+
new_data_df = df[mask].copy()
|
|
211
|
+
new_data_df['Timestamp'] = pd.to_datetime(new_data_df['Timestamp'], format='mixed').dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
212
|
+
new_data_df['Code'] = new_data_df['Code'].astype(str)
|
|
213
|
+
logger.info(f"新規データ {len(new_data_df)} 件を追加します(銘柄コード: {code})")
|
|
214
|
+
self._batch_insert_data(db, table_name, new_data_df)
|
|
215
|
+
else:
|
|
216
|
+
logger.info(f"新規データはありません(銘柄コード: {code})")
|
|
217
|
+
|
|
218
|
+
else:
|
|
219
|
+
logger.info(f"新しいテーブル {table_name} を作成します")
|
|
220
|
+
df_normalized = df.copy()
|
|
221
|
+
df_normalized['Timestamp'] = pd.to_datetime(df_normalized['Timestamp'], format='mixed').dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
222
|
+
primary_keys = ['Code', 'Timestamp']
|
|
223
|
+
self._create_table_from_dataframe(db, table_name, df_normalized, primary_keys)
|
|
224
|
+
db.execute(f'CREATE INDEX IF NOT EXISTS idx_{table_name}_Code ON {table_name}("Code")')
|
|
225
|
+
db.execute(f'CREATE INDEX IF NOT EXISTS idx_{table_name}_Timestamp ON {table_name}("Timestamp")')
|
|
226
|
+
self._batch_insert_data(db, table_name, df_normalized)
|
|
227
|
+
|
|
228
|
+
# メタデータの保存
|
|
229
|
+
timestamp_stats = db.execute(
|
|
230
|
+
f'SELECT MIN("Timestamp") as min_ts, MAX("Timestamp") as max_ts, COUNT(*) as count FROM {table_name} WHERE "Code" = ?',
|
|
231
|
+
[code]
|
|
232
|
+
).fetchone()
|
|
233
|
+
|
|
234
|
+
if timestamp_stats and timestamp_stats[0]:
|
|
235
|
+
actual_from = str(timestamp_stats[0])
|
|
236
|
+
actual_to = str(timestamp_stats[1])
|
|
237
|
+
actual_count = timestamp_stats[2]
|
|
238
|
+
|
|
239
|
+
self._save_metadata(db, code, actual_from, actual_to, actual_count)
|
|
240
|
+
|
|
241
|
+
# トランザクションコミット
|
|
242
|
+
db.execute("COMMIT")
|
|
243
|
+
logger.info(f"板情報をDuckDBに保存しました: 銘柄コード={code}, 件数={len(df)}")
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
db.execute("ROLLBACK")
|
|
247
|
+
raise e
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.error(f"板情報の保存に失敗しました: {str(e)}", exc_info=True)
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_stock_board_from_cache(self, code: str, at: datetime) -> pd.DataFrame:
|
|
255
|
+
"""
|
|
256
|
+
指定時刻の板情報をDuckDBから取得(指定時刻以前で最も近いデータを返す)
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
code (str): 銘柄コード
|
|
260
|
+
at (datetime): 取得時刻
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
pd.DataFrame: 板情報データ(1行)
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
if not self.isEnable:
|
|
267
|
+
return pd.DataFrame()
|
|
268
|
+
|
|
269
|
+
if at is None:
|
|
270
|
+
return pd.DataFrame()
|
|
271
|
+
|
|
272
|
+
if isinstance(at, str):
|
|
273
|
+
at = datetime.strptime(at, '%Y-%m-%d %H:%M:%S')
|
|
274
|
+
target_timestamp = at.strftime('%Y-%m-%d %H:%M:%S')
|
|
275
|
+
# 同じ日の範囲に限定
|
|
276
|
+
date_start = at.strftime('%Y-%m-%d') + ' 00:00:00'
|
|
277
|
+
date_end = at.strftime('%Y-%m-%d') + ' 23:59:59'
|
|
278
|
+
|
|
279
|
+
table_name = "stocks_board"
|
|
280
|
+
|
|
281
|
+
# 検索するコードのリスト(元のコード、見つからなければ末尾に0を追加)
|
|
282
|
+
codes_to_try = [code]
|
|
283
|
+
if len(code) == 4:
|
|
284
|
+
codes_to_try.append(code + '0')
|
|
285
|
+
|
|
286
|
+
with self.get_db(code) as db:
|
|
287
|
+
|
|
288
|
+
if not self._table_exists(db, table_name):
|
|
289
|
+
logger.debug(f"テーブル {table_name} が存在しません: {code}")
|
|
290
|
+
return pd.DataFrame()
|
|
291
|
+
|
|
292
|
+
for search_code in codes_to_try:
|
|
293
|
+
# 指定時刻以前で最も近いデータを取得(同じ日に限定)
|
|
294
|
+
query = f'''
|
|
295
|
+
SELECT * FROM {table_name}
|
|
296
|
+
WHERE "Code" = ? AND "Timestamp" <= ? AND "Timestamp" >= ?
|
|
297
|
+
ORDER BY "Timestamp" DESC
|
|
298
|
+
LIMIT 1
|
|
299
|
+
'''
|
|
300
|
+
df = db.execute(query, [search_code, target_timestamp, date_start]).fetchdf()
|
|
301
|
+
|
|
302
|
+
# 指定時刻以前にデータがなければ、指定時刻以後で最も近いデータを取得(同じ日に限定)
|
|
303
|
+
if df.empty:
|
|
304
|
+
query_after = f'''
|
|
305
|
+
SELECT * FROM {table_name}
|
|
306
|
+
WHERE "Code" = ? AND "Timestamp" > ? AND "Timestamp" <= ?
|
|
307
|
+
ORDER BY "Timestamp" ASC
|
|
308
|
+
LIMIT 1
|
|
309
|
+
'''
|
|
310
|
+
df = db.execute(query_after, [search_code, target_timestamp, date_end]).fetchdf()
|
|
311
|
+
|
|
312
|
+
if not df.empty:
|
|
313
|
+
logger.info(f"板情報をDuckDBから読み込みました: {search_code} (時刻: {df['Timestamp'].iloc[0]})")
|
|
314
|
+
return df
|
|
315
|
+
|
|
316
|
+
logger.info(f"指定日 {at.strftime('%Y-%m-%d')} の板情報がありません: {code}")
|
|
317
|
+
return pd.DataFrame()
|
|
318
|
+
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.error(f"板情報の読み込みに失敗しました: {str(e)}", exc_info=True)
|
|
321
|
+
return pd.DataFrame()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def ensure_db_ready(self, code: str) -> None:
|
|
325
|
+
"""
|
|
326
|
+
DuckDBファイルの準備を行う(存在しなければFTPからダウンロードを試行)
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
code (str): 銘柄コード
|
|
330
|
+
"""
|
|
331
|
+
if not self.isEnable:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# コードの正規化(サフィックス除去)
|
|
335
|
+
normalized_code = code
|
|
336
|
+
if len(code) > 4:
|
|
337
|
+
normalized_code = code[:-1]
|
|
338
|
+
|
|
339
|
+
db_path = os.path.join(self.cache_dir, "stocks_board", f"{normalized_code}.duckdb")
|
|
340
|
+
|
|
341
|
+
if not os.path.exists(db_path):
|
|
342
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
343
|
+
# FTPからダウンロードを試行
|
|
344
|
+
if self._download_from_ftp(normalized_code, db_path):
|
|
345
|
+
logger.info(f"DuckDBファイルをFTPからダウンロードしました: {db_path}")
|
|
346
|
+
else:
|
|
347
|
+
logger.debug(f"FTPにDuckDBファイルが存在しません: {normalized_code}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@contextmanager
|
|
351
|
+
def get_db(self, code: str):
|
|
352
|
+
"""
|
|
353
|
+
DuckDBデータベース接続を取得
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
code (str): 銘柄コード
|
|
357
|
+
|
|
358
|
+
Yields:
|
|
359
|
+
duckdb.DuckDBPyConnection: DuckDB接続オブジェクト
|
|
360
|
+
"""
|
|
361
|
+
db_path = os.path.join(self.cache_dir, "stocks_board", f"{code}.duckdb")
|
|
362
|
+
if not os.path.exists(db_path):
|
|
363
|
+
if len(code) > 4:
|
|
364
|
+
code_retry = code[:-1]
|
|
365
|
+
# 再帰呼び出し: サフィックスを除去してリトライ
|
|
366
|
+
with self.get_db(code_retry) as db:
|
|
367
|
+
yield db
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
371
|
+
# FTPからダウンロードを試行
|
|
372
|
+
if self._download_from_ftp(code, db_path):
|
|
373
|
+
logger.info(f"DuckDBファイルをFTPからダウンロードしました: {db_path}")
|
|
374
|
+
else:
|
|
375
|
+
logger.info(f"DuckDBファイルを作成しました: {db_path}")
|
|
376
|
+
|
|
377
|
+
db = duckdb.connect(db_path)
|
|
378
|
+
try:
|
|
379
|
+
yield db
|
|
380
|
+
finally:
|
|
381
|
+
db.close()
|
|
382
|
+
|
|
383
|
+
def _download_from_ftp(self, code: str, local_path: str) -> bool:
|
|
384
|
+
"""
|
|
385
|
+
FTPサーバーからDuckDBファイルをダウンロード
|
|
386
|
+
"""
|
|
387
|
+
import ftplib
|
|
388
|
+
|
|
389
|
+
FTP_HOST = 'backcast.i234.me'
|
|
390
|
+
FTP_USER = 'sasaco_worker'
|
|
391
|
+
FTP_PASSWORD = 'S#1y9c%7o9'
|
|
392
|
+
FTP_PORT = 21
|
|
393
|
+
REMOTE_DIR = '/StockData/jp/stocks_board'
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
with ftplib.FTP() as ftp:
|
|
397
|
+
ftp.connect(FTP_HOST, FTP_PORT)
|
|
398
|
+
ftp.login(FTP_USER, FTP_PASSWORD)
|
|
399
|
+
|
|
400
|
+
remote_file = f"{REMOTE_DIR}/{code}.duckdb"
|
|
401
|
+
|
|
402
|
+
# ファイルサイズ確認(存在確認も兼ねる)
|
|
403
|
+
try:
|
|
404
|
+
ftp.voidcmd(f"TYPE I")
|
|
405
|
+
size = ftp.size(remote_file)
|
|
406
|
+
if size is None: # sizeコマンドがサポートされていない場合のフォールバックは省略
|
|
407
|
+
pass
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.debug(f"FTPサーバーにファイルが見つかりません: {remote_file}")
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
logger.info(f"FTPダウンロード開始: {remote_file} -> {local_path}")
|
|
413
|
+
|
|
414
|
+
with open(local_path, 'wb') as f:
|
|
415
|
+
ftp.retrbinary(f"RETR {remote_file}", f.write)
|
|
416
|
+
|
|
417
|
+
logger.info(f"FTPダウンロード完了: {local_path}")
|
|
418
|
+
return True
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.warning(f"FTPダウンロード失敗: {e}")
|
|
422
|
+
# ダウンロード中の不完全なファイルが残っている場合は削除
|
|
423
|
+
if os.path.exists(local_path):
|
|
424
|
+
try:
|
|
425
|
+
os.remove(local_path)
|
|
426
|
+
except:
|
|
427
|
+
pass
|
|
428
|
+
return False
|