quantmine 0.2.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.
- quantmine/__init__.py +97 -0
- quantmine/back_testing.py +381 -0
- quantmine/config.py +115 -0
- quantmine/data_acquisition.py +326 -0
- quantmine/datareader.py +119 -0
- quantmine/factor_attribution.py +70 -0
- quantmine/factor_mining.py +192 -0
- quantmine/factor_register.py +73 -0
- quantmine/ic_calculator.py +543 -0
- quantmine/load_config.py +16 -0
- quantmine-0.2.0.dist-info/METADATA +222 -0
- quantmine-0.2.0.dist-info/RECORD +14 -0
- quantmine-0.2.0.dist-info/WHEEL +4 -0
- quantmine-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import yfinance as yf
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import glob
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import os
|
|
8
|
+
import hashlib
|
|
9
|
+
import pandas_datareader.data as web
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
def is_delisted_error(msg: str, ignore_json: json)-> bool:
|
|
13
|
+
"""Check whether an error message matches any delisting keyword.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
msg: Error message to inspect.
|
|
17
|
+
ignore_json: Iterable of keyword strings used to detect delisting.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
True if any keyword appears in the message, otherwise False.
|
|
21
|
+
"""
|
|
22
|
+
msg = msg.lower()
|
|
23
|
+
return any(kw in msg for kw in ignore_json)
|
|
24
|
+
|
|
25
|
+
def load_blacklist(checkpoint_dir:str)-> set :
|
|
26
|
+
"""Load the ticker blacklist from the checkpoint directory.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
checkpoint_dir: Directory containing ``blacklist.json``.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A set of blacklisted tickers. Returns an empty set if the file does not
|
|
33
|
+
exist.
|
|
34
|
+
"""
|
|
35
|
+
path = os.path.join(checkpoint_dir, "blacklist.json")
|
|
36
|
+
if not os.path.exists(path):
|
|
37
|
+
return set()
|
|
38
|
+
with open(path) as f:
|
|
39
|
+
return set(json.load(f))
|
|
40
|
+
|
|
41
|
+
def get_ff3(start_date: str, end_date: str, save_path: str, format: Literal['csv', 'parquet']):
|
|
42
|
+
if format not in ('csv', 'parquet'):
|
|
43
|
+
raise ValueError(f'{format} format error')
|
|
44
|
+
start_date = pd.to_datetime(start_date)
|
|
45
|
+
end_date = pd.to_datetime(end_date)
|
|
46
|
+
output_path = os.path.join(os.getcwd(), save_path)
|
|
47
|
+
|
|
48
|
+
if not os.path.exists(output_path):
|
|
49
|
+
os.makedirs(output_path)
|
|
50
|
+
|
|
51
|
+
ff3 = web.DataReader('F-F_Research_Data_Factors', 'famafrench', start_date, end_date)
|
|
52
|
+
if format == 'csv':
|
|
53
|
+
ff3.to_csv(os.path.join(output_path, 'F-F_Research_Data_Factors.csv'))
|
|
54
|
+
if format == 'parquet':
|
|
55
|
+
ff3.to_parquet(os.path.join(output_path, 'F-F_Research_Data_Factors.parquet'))
|
|
56
|
+
return ff3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_blacklist(keyword: list | str, checkpoint_dir:list):
|
|
60
|
+
"""Add one or more keywords to the blacklist file.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
keyword: A single keyword or a list of keywords to add.
|
|
64
|
+
checkpoint_dir: Directory where the blacklist file is stored.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
None.
|
|
68
|
+
|
|
69
|
+
Notes:
|
|
70
|
+
The directory is created automatically if it does not already exist.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(keyword, str):
|
|
73
|
+
keyword = [keyword]
|
|
74
|
+
os.makedirs(checkpoint_dir, exist_ok=True)
|
|
75
|
+
existing = load_blacklist(checkpoint_dir)
|
|
76
|
+
updated = list(existing | set(keyword))
|
|
77
|
+
|
|
78
|
+
blacklist_path = os.path.join(checkpoint_dir, "blacklist.json")
|
|
79
|
+
with open(blacklist_path, "w") as f:
|
|
80
|
+
json.dump(updated, f, indent=2)
|
|
81
|
+
print(f"blacklist updated: add{keyword}, total {len(keyword)}")
|
|
82
|
+
|
|
83
|
+
def save_blacklist(tickers: list, checkpoint_dir:str):
|
|
84
|
+
"""Persist a list of tickers into the blacklist file.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
tickers: Tickers to append to the blacklist.
|
|
88
|
+
checkpoint_dir: Directory where ``blacklist.json`` is stored.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
None.
|
|
92
|
+
"""
|
|
93
|
+
path = os.path.join(checkpoint_dir, "blacklist.json")
|
|
94
|
+
existing = load_blacklist(path)
|
|
95
|
+
updated = list(existing | set(tickers))
|
|
96
|
+
with open(path, "w") as f:
|
|
97
|
+
json.dump(updated,f, indent=2)
|
|
98
|
+
print(f"Blacklist updated: {updated}")
|
|
99
|
+
|
|
100
|
+
def data_acquisition(tickers:list, start_date:str, end_date:str, batch_size:int,
|
|
101
|
+
max_retries: int =3, wait: int = 60, checkpoint_dir: str = "tmp/checkpoint") -> pd.DataFrame:
|
|
102
|
+
"""Download historical price and volume data in batches with checkpoints.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tickers: List of tickers to download.
|
|
106
|
+
start_date: Start date in ``YYYY-MM-DD`` format.
|
|
107
|
+
end_date: End date in ``YYYY-MM-DD`` format.
|
|
108
|
+
batch_size: Number of tickers per download batch.
|
|
109
|
+
max_retries: Maximum retries for each batch download.
|
|
110
|
+
wait: Seconds to wait between retry attempts.
|
|
111
|
+
checkpoint_dir: Directory used to store batch checkpoints and logs.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A tuple of ``(close_dataframe, volume_dataframe)``.
|
|
115
|
+
|
|
116
|
+
Notes:
|
|
117
|
+
Each batch is cached to disk so repeated runs can resume from existing
|
|
118
|
+
checkpoint files.
|
|
119
|
+
"""
|
|
120
|
+
close=[]
|
|
121
|
+
volume=[]
|
|
122
|
+
task_signature = hashlib.md5(f'{sorted(tickers)}_{start_date}_{end_date}'.encode()).hexdigest()[:8]
|
|
123
|
+
task_checkpoint_dir = os.path.join(checkpoint_dir,task_signature)
|
|
124
|
+
os.makedirs(task_checkpoint_dir,exist_ok=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def download_batch_with_retry(batch: list, start_date: str, end_date:str, batch_index:int ,max_retries:int =3, wait: int =60) ->pd.DataFrame | None :
|
|
128
|
+
checkpoint_path = os.path.join(task_checkpoint_dir, f"batch_{batch_index}.parquet")
|
|
129
|
+
if os.path.exists(checkpoint_path):
|
|
130
|
+
print(f"{batch_index} already exist")
|
|
131
|
+
return pd.read_parquet(checkpoint_path)
|
|
132
|
+
|
|
133
|
+
for attempt in range(max_retries):
|
|
134
|
+
try:
|
|
135
|
+
data = yf.download(batch, start=start_date, end=end_date,
|
|
136
|
+
auto_adjust=True, progress=False)
|
|
137
|
+
if data.empty:
|
|
138
|
+
raise ValueError("Empty data returned")
|
|
139
|
+
if isinstance(data.columns, pd.MultiIndex):
|
|
140
|
+
close = data["Close"]
|
|
141
|
+
empty_tickers = close.columns[close.isnull().all()].tolist()
|
|
142
|
+
if empty_tickers:
|
|
143
|
+
save_blacklist(empty_tickers, task_checkpoint_dir)
|
|
144
|
+
print(f"Blacklisted :{empty_tickers}")
|
|
145
|
+
data.to_parquet(checkpoint_path)
|
|
146
|
+
return data
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"Attempt {attempt+1} failed: {e}")
|
|
149
|
+
if attempt < max_retries - 1:
|
|
150
|
+
print(f"Retrying after {wait} seconds...")
|
|
151
|
+
time.sleep(wait)
|
|
152
|
+
with open(os.path.join(task_checkpoint_dir, "failed_batches.json"), mode= "a") as f:
|
|
153
|
+
failed_log = os.path.join(task_checkpoint_dir, "failed_batches.json")
|
|
154
|
+
existing_failed = set()
|
|
155
|
+
if os.path.exists(failed_log):#先读已有的记录,去重后再写
|
|
156
|
+
with open(failed_log) as f:
|
|
157
|
+
for line in f:
|
|
158
|
+
existing_failed.add(json.loads(line)["batch_index"])
|
|
159
|
+
|
|
160
|
+
if batch_index not in existing_failed:
|
|
161
|
+
with open(failed_log, mode="a") as f:
|
|
162
|
+
json.dump({"batch_index": batch_index, "tickers": batch}, f)
|
|
163
|
+
f.write("\n")
|
|
164
|
+
print(f"Batch {batch_index} failed, logging to {os.path.join(checkpoint_dir, 'failed_batches.json')}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
all_close = []
|
|
168
|
+
all_volume = []
|
|
169
|
+
for i in range(0, len(tickers), batch_size):
|
|
170
|
+
batch = tickers[i: i+batch_size]
|
|
171
|
+
batch_index= i//batch_size+1
|
|
172
|
+
print(f"downloading batch{batch_index} ... \ntickers {i} - {min(len(tickers), (i+batch_size))}")
|
|
173
|
+
data = download_batch_with_retry(batch=batch, start_date=start_date, end_date=end_date, max_retries=max_retries, wait=wait, batch_index=batch_index)
|
|
174
|
+
if data is not None:
|
|
175
|
+
if isinstance(data.columns, pd.MultiIndex):
|
|
176
|
+
all_close.append(data['Close'])
|
|
177
|
+
all_volume.append(data['Volume'])
|
|
178
|
+
time.sleep(10)
|
|
179
|
+
|
|
180
|
+
close = pd.concat(all_close, axis=1)
|
|
181
|
+
volume = pd.concat(all_volume, axis=1)
|
|
182
|
+
return close, volume
|
|
183
|
+
|
|
184
|
+
def retry_batches(start_date: str, end_date: str, max_retries: int, checkpoint_dir: str = "tmp/checkpoint", wait: int =60)->pd.DataFrame | None:
|
|
185
|
+
"""Retry batches that previously failed to download.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
start_date: Start date in ``YYYY-MM-DD`` format.
|
|
189
|
+
end_date: End date in ``YYYY-MM-DD`` format.
|
|
190
|
+
max_retries: Maximum retries per failed batch.
|
|
191
|
+
checkpoint_dir: Directory containing failure logs and checkpoints.
|
|
192
|
+
wait: Seconds to wait between retry attempts.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
A tuple of ``(close_dataframe, volume_dataframe)`` if any batch is
|
|
196
|
+
recovered, otherwise ``(None, None)``.
|
|
197
|
+
|
|
198
|
+
Notes:
|
|
199
|
+
The function reads ``failed_batches.json`` and retries only the batches
|
|
200
|
+
recorded there.
|
|
201
|
+
"""
|
|
202
|
+
black_list = load_blacklist(checkpoint_dir)
|
|
203
|
+
failed_log = os.path.join(checkpoint_dir, "failed_batches.json") #读取失败日志
|
|
204
|
+
retry_log = os.path.join(checkpoint_dir, "retry_log.json") #读取重试日志
|
|
205
|
+
if not os.path.exists(failed_log):
|
|
206
|
+
print("No failed batches need to be processed")
|
|
207
|
+
return None, None
|
|
208
|
+
failed = []
|
|
209
|
+
with open(failed_log) as f:
|
|
210
|
+
for line in f:
|
|
211
|
+
failed.append(json.loads(line))
|
|
212
|
+
|
|
213
|
+
retry_count = 1
|
|
214
|
+
if os.path.exists(retry_log):
|
|
215
|
+
with open(retry_log) as f: #读取重试日志,获取上一次是第几次重试
|
|
216
|
+
records = [json.loads(line) for line in f if line.strip()]
|
|
217
|
+
if records:
|
|
218
|
+
retry_count = records[-1]["retry_call"] +1
|
|
219
|
+
print(f"Retry round {retry_count}, {len(failed)} batches failed in total")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
still_failed=[]
|
|
223
|
+
retry_records = []
|
|
224
|
+
success_close=[]
|
|
225
|
+
success_volume=[]
|
|
226
|
+
|
|
227
|
+
for record in failed:
|
|
228
|
+
batch_index = record["batch_index"]
|
|
229
|
+
batch = record["tickers"]
|
|
230
|
+
|
|
231
|
+
checkpoint_path = os.path.join(checkpoint_dir, f"batch_{batch_index}.parquet")
|
|
232
|
+
print(f"Retrying batch {batch_index}")
|
|
233
|
+
success = False
|
|
234
|
+
for attempt in range(max_retries):
|
|
235
|
+
try:
|
|
236
|
+
data = yf.download(batch, start=start_date, end=end_date,
|
|
237
|
+
auto_adjust=True, progress=False)
|
|
238
|
+
if data.empty:
|
|
239
|
+
raise ValueError("Empty data returned")
|
|
240
|
+
retry_records.append({
|
|
241
|
+
"retry_call":retry_count,
|
|
242
|
+
"batch_index": batch_index,
|
|
243
|
+
"status": "success",
|
|
244
|
+
"attempts": attempt + 1
|
|
245
|
+
})
|
|
246
|
+
data.to_parquet(checkpoint_path)
|
|
247
|
+
if isinstance(data.columns, pd.MultiIndex):
|
|
248
|
+
success_close.append(data['Close'])
|
|
249
|
+
success_volume.append(data['Volume'])
|
|
250
|
+
print(f"{batch} succeeded!")
|
|
251
|
+
success = True
|
|
252
|
+
break
|
|
253
|
+
except Exception as e:
|
|
254
|
+
print(f"Attempt {attempt+1} failed: {e}")
|
|
255
|
+
if attempt < max_retries - 1:
|
|
256
|
+
print(f"Retrying after {wait} seconds...")
|
|
257
|
+
time.sleep(wait)
|
|
258
|
+
if not success:
|
|
259
|
+
print(f"Batch {batch} still failed to download")
|
|
260
|
+
retry_records.append({
|
|
261
|
+
"retry_call":retry_count,
|
|
262
|
+
"batch_index": batch_index,
|
|
263
|
+
"status": "failed",
|
|
264
|
+
"attempts": max_retries
|
|
265
|
+
})
|
|
266
|
+
still_failed.append(record)
|
|
267
|
+
|
|
268
|
+
if not batch:
|
|
269
|
+
print(f"Batch {record["batch_index"]} is fully delisted, skipping")
|
|
270
|
+
retry_records.append({
|
|
271
|
+
"retry_call": retry_count,
|
|
272
|
+
"batch_index":record["batch_index"],
|
|
273
|
+
"status":"skipped_blacklist",
|
|
274
|
+
"attempts":0,
|
|
275
|
+
})
|
|
276
|
+
continue
|
|
277
|
+
batch = [t for t in record["tickers"] if t not in black_list]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
with open(failed_log, "w") as f:
|
|
281
|
+
for record in still_failed: #将仍失败的任务写在失败日志里
|
|
282
|
+
json.dump(record, f)
|
|
283
|
+
f.write("\n")
|
|
284
|
+
|
|
285
|
+
with open(retry_log, "a") as f: #将重试记录写在重试的日志里
|
|
286
|
+
for record in retry_records:
|
|
287
|
+
json.dump(record, f)
|
|
288
|
+
f.write("\n")
|
|
289
|
+
if still_failed:
|
|
290
|
+
print(f"Still {len(still_failed)} batches failed to download")
|
|
291
|
+
else:
|
|
292
|
+
print("All previously failed batches succeeded")
|
|
293
|
+
if success_volume:
|
|
294
|
+
volume = pd.concat(success_volume,axis=1)
|
|
295
|
+
close = pd.concat(success_close,axis=1)
|
|
296
|
+
return close, volume
|
|
297
|
+
else:
|
|
298
|
+
return None, None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def merge_checkpoints(checkpoint_dir: str = "tmp/checkpoint") -> tuple:
|
|
302
|
+
"""Merge all batch checkpoint files into full close and volume tables.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
checkpoint_dir: Directory containing ``batch_*.parquet`` checkpoint
|
|
306
|
+
files.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
A tuple of ``(close_dataframe, volume_dataframe)``.
|
|
310
|
+
|
|
311
|
+
Notes:
|
|
312
|
+
Only parquet files with a MultiIndex column layout are merged.
|
|
313
|
+
"""
|
|
314
|
+
files = sorted(glob.glob(os.path.join(checkpoint_dir, "batch_*.parquet")),
|
|
315
|
+
key=lambda x: int(x.split("batch_")[1].split(".")[0]))
|
|
316
|
+
|
|
317
|
+
all_close, all_volume = [], []
|
|
318
|
+
for f in files:
|
|
319
|
+
data = pd.read_parquet(f)
|
|
320
|
+
if isinstance(data.columns, pd.MultiIndex):
|
|
321
|
+
all_close.append(data["Close"])
|
|
322
|
+
all_volume.append(data["Volume"])
|
|
323
|
+
|
|
324
|
+
close = pd.concat(all_close, axis=1)
|
|
325
|
+
volume = pd.concat(all_volume, axis=1)
|
|
326
|
+
return close, volume
|
quantmine/datareader.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import TypeAlias, Protocol
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class MarketData:
|
|
8
|
+
close: pd.DataFrame = None
|
|
9
|
+
volume: pd.DataFrame = None
|
|
10
|
+
market_cap : pd.DataFrame = None
|
|
11
|
+
def require(self, *fields: str)->None:
|
|
12
|
+
for field in fields:
|
|
13
|
+
if getattr(self, field) is None:
|
|
14
|
+
raise ValueError(f'You have not provided the data for "{field}"')
|
|
15
|
+
|
|
16
|
+
class DataSource(Protocol):
|
|
17
|
+
def load(self, tickers: list[str], start: str, end: str )-> MarketData:
|
|
18
|
+
... #ellipsis: 占位符,约定俗成这个函数体是空的,只是展位
|
|
19
|
+
|
|
20
|
+
class LocalFileSource(ABC):
|
|
21
|
+
def __init__(self, paths: dict[str]):
|
|
22
|
+
self.paths = paths
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def _read_file(self, path:str)->pd.DataFrame:
|
|
25
|
+
...
|
|
26
|
+
def load(self, tickers: list[str] =None, start: str=None, end:str= None)-> MarketData:
|
|
27
|
+
loaded = {}
|
|
28
|
+
for filed_name, path in self.paths.items():
|
|
29
|
+
df = self._read_file(path)
|
|
30
|
+
if tickers:
|
|
31
|
+
loaded[filed_name] = df.loc[start:end, tickers]
|
|
32
|
+
else:
|
|
33
|
+
loaded[filed_name] = df.loc[start:end]
|
|
34
|
+
return MarketData(**loaded)
|
|
35
|
+
|
|
36
|
+
class YFinanceSource:
|
|
37
|
+
def __init__(self, batch_size: int =50, max_retries: int=3, wait: int=60, checkpoint_dir:str="tmp/checkpoint"):
|
|
38
|
+
self.batch_size = batch_size
|
|
39
|
+
self.max_retries = max_retries
|
|
40
|
+
self.wait = wait
|
|
41
|
+
self.checkpoint_dir = checkpoint_dir
|
|
42
|
+
def load(self, tickers: list[str], start: str, end: str) ->MarketData:
|
|
43
|
+
#惰性import: 只用本地文件源的用户不必安装yfinance依赖链
|
|
44
|
+
from data_acquisition import data_acquisition
|
|
45
|
+
close, volume = data_acquisition(tickers=tickers, start_date = start, end_date = end, batch_size= self.batch_size,
|
|
46
|
+
max_retries= self.max_retries, wait= self.wait, checkpoint_dir=self.checkpoint_dir)
|
|
47
|
+
return MarketData(close = close, volume = volume)
|
|
48
|
+
|
|
49
|
+
class ParquetSource(LocalFileSource):
|
|
50
|
+
def _read_file(self, path:str) -> pd.DataFrame:
|
|
51
|
+
return pd.read_parquet(path)
|
|
52
|
+
|
|
53
|
+
class CSVSource(LocalFileSource):
|
|
54
|
+
def _read_file(self, path):
|
|
55
|
+
return pd.read_csv(path, index_col = 0, parse_dates= True)
|
|
56
|
+
|
|
57
|
+
class ExcelSource(LocalFileSource):
|
|
58
|
+
def _read_file(self, path):
|
|
59
|
+
return pd.read_excel(path, index_col=0, parse_dates= True)
|
|
60
|
+
|
|
61
|
+
class ConstituentsSource(Protocol):
|
|
62
|
+
"""Point-in-time股票池数据源协议。
|
|
63
|
+
|
|
64
|
+
回测在每个调仓日调用get_constituents(date)获取当日有效宇宙,
|
|
65
|
+
任何实现了该方法的对象都可以接入(静态名单/区间表/实时API)。
|
|
66
|
+
"""
|
|
67
|
+
def get_constituents(self, date: pd.Timestamp) -> set[str]:
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
class StaticUniverse:
|
|
71
|
+
"""固定股票池: 没有point-in-time成分股数据的用户的最简实现。
|
|
72
|
+
|
|
73
|
+
注意: 固定宇宙意味着回测带有幸存者偏差, 结论解读时需要声明。
|
|
74
|
+
"""
|
|
75
|
+
def __init__(self, tickers: list[str] | set[str]):
|
|
76
|
+
self._tickers = set(tickers)
|
|
77
|
+
def get_constituents(self, date: pd.Timestamp) -> set[str]:
|
|
78
|
+
return self._tickers
|
|
79
|
+
|
|
80
|
+
class MembershipTableSource:
|
|
81
|
+
"""区间表实现: 从 (ticker, start_date, end_date) 表构造point-in-time宇宙。
|
|
82
|
+
|
|
83
|
+
end_date为空表示至今仍在指数内。日期解析在构造时一次完成
|
|
84
|
+
(每次查询重复parse是逐调仓日的全表开销), 查询结果按日期缓存。
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
table: 含ticker/start_date/end_date三列的DataFrame。
|
|
88
|
+
normalize: True时把ticker里的'.'替换成'-'(如BRK.B -> BRK-B),
|
|
89
|
+
与yfinance列名对齐; 若你的行情数据用'.'写法则传False。
|
|
90
|
+
"""
|
|
91
|
+
def __init__(self, table: pd.DataFrame, ticker_col: str = 'ticker',
|
|
92
|
+
start_col: str = 'start_date', end_col: str = 'end_date',
|
|
93
|
+
normalize: bool = True):
|
|
94
|
+
self._start = pd.to_datetime(table[start_col])
|
|
95
|
+
self._end = pd.to_datetime(table[end_col])
|
|
96
|
+
tickers = table[ticker_col].astype(str)
|
|
97
|
+
if normalize:
|
|
98
|
+
tickers = tickers.str.replace('.', '-', regex=False)
|
|
99
|
+
self._tickers = tickers
|
|
100
|
+
self._cache: dict[pd.Timestamp, set[str]] = {}
|
|
101
|
+
|
|
102
|
+
def get_constituents(self, date: pd.Timestamp) -> set[str]:
|
|
103
|
+
date = pd.Timestamp(date)
|
|
104
|
+
if date not in self._cache:
|
|
105
|
+
#end条件必须整体括起来, 否则end>=date的行会绕过start<=date的检查
|
|
106
|
+
mask = (self._start <= date) & (self._end.isnull() | (self._end >= date))
|
|
107
|
+
self._cache[date] = set(self._tickers[mask])
|
|
108
|
+
return self._cache[date]
|
|
109
|
+
|
|
110
|
+
FactorDict: TypeAlias = dict[str, pd.DataFrame]
|
|
111
|
+
ForwardReturn: TypeAlias = dict[int, pd.DataFrame]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
yfinance_data = YFinanceSource()
|
|
118
|
+
apple_data = yfinance_data.load(tickers = ['AAPL'],start = '2026-07-02', end = '2026-07-08')
|
|
119
|
+
print(apple_data)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Carhart four-factor attribution of the long-short backtest returns.
|
|
2
|
+
|
|
3
|
+
Regresses the daily long-short return series on the Fama-French three factors
|
|
4
|
+
plus momentum (all taken from the Ken French Data Library) with Newey-West
|
|
5
|
+
(HAC) standard errors. This answers whether the strategy return is explained
|
|
6
|
+
by known risk premia or carries unexplained alpha.
|
|
7
|
+
|
|
8
|
+
Factor data (not included in the repo, download from the Ken French Data
|
|
9
|
+
Library and place under ``tmp/ff3/``):
|
|
10
|
+
- F-F_Research_Data_Factors_daily.csv
|
|
11
|
+
- F-F_Momentum_Factor_daily.csv
|
|
12
|
+
"""
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import statsmodels.api as sm
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_french_factors(ff3_path: str, mom_path: str) -> pd.DataFrame:
|
|
18
|
+
"""Load and merge daily FF3 and momentum factors from Ken French CSVs.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
ff3_path: Path to ``F-F_Research_Data_Factors_daily.csv``.
|
|
22
|
+
mom_path: Path to ``F-F_Momentum_Factor_daily.csv``.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A dataframe indexed by date with columns ``Mkt-RF``, ``SMB``, ``HML``,
|
|
26
|
+
``RF`` and ``Mom``, converted from percent to decimal returns.
|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
The raw CSVs carry header/footer junk rows; only rows whose date field
|
|
30
|
+
matches ``YYYYMMDD`` are kept.
|
|
31
|
+
"""
|
|
32
|
+
ff3 = pd.read_csv(ff3_path, skiprows=4)
|
|
33
|
+
ff3.columns = ['Date'] + list(ff3.columns[1:])
|
|
34
|
+
ff3 = ff3[ff3['Date'].astype(str).str.match(r'^\d{8}$')]
|
|
35
|
+
|
|
36
|
+
mom = pd.read_csv(mom_path, skiprows=13)
|
|
37
|
+
mom = mom.iloc[:, :2]
|
|
38
|
+
mom.columns = ['Date', 'Mom']
|
|
39
|
+
mom = mom[mom['Date'].astype(str).str.match(r'^\d{8}$')]
|
|
40
|
+
|
|
41
|
+
merged = ff3.merge(mom, on='Date', how='inner')
|
|
42
|
+
merged['date'] = pd.to_datetime(merged['Date'], format='%Y%m%d')
|
|
43
|
+
cols = ['Mkt-RF', 'SMB', 'HML', 'RF', 'Mom']
|
|
44
|
+
merged[cols] = merged[cols].astype(float) / 100 #percent -> decimal
|
|
45
|
+
return merged.set_index('date')[cols]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def carhart_attribution(daily_returns: pd.DataFrame, factors: pd.DataFrame, maxlags: int = 20):
|
|
49
|
+
"""Regress the long-short series on Mkt-RF, SMB, HML and Mom.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
daily_returns: Wide dataframe containing a ``long_short`` column of
|
|
53
|
+
daily returns (output of ``expand_to_daily_returns``).
|
|
54
|
+
factors: Daily factor dataframe from ``load_french_factors``.
|
|
55
|
+
maxlags: Newey-West lag length for the HAC covariance. Default 20 to
|
|
56
|
+
match the longest holding period.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A fitted statsmodels OLS results object. ``params['const']`` is the
|
|
60
|
+
daily alpha; multiply by 252 to annualize.
|
|
61
|
+
|
|
62
|
+
Notes:
|
|
63
|
+
The long-short portfolio is self-financing, so the raw spread (not the
|
|
64
|
+
excess over RF) is regressed.
|
|
65
|
+
"""
|
|
66
|
+
combined = (daily_returns[['long_short']]
|
|
67
|
+
.join(factors[['Mkt-RF', 'SMB', 'HML', 'Mom']], how='inner')
|
|
68
|
+
.dropna())
|
|
69
|
+
x = sm.add_constant(combined[['Mkt-RF', 'SMB', 'HML', 'Mom']])
|
|
70
|
+
return sm.OLS(combined['long_short'], x).fit(cov_type='HAC', cov_kwds={'maxlags': maxlags})
|