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
quantmine/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""quantmine: an honest-statistics equity factor research library.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
import quantmine as qf
|
|
5
|
+
|
|
6
|
+
data = qf.MarketData(close=close_df, volume=volume_df)
|
|
7
|
+
|
|
8
|
+
@qf.factor_register("my_factor")
|
|
9
|
+
def my_factor(close, tickers):
|
|
10
|
+
return -close[tickers].pct_change(5)
|
|
11
|
+
|
|
12
|
+
pool = qf.build_param_pool(data, day=5, halflife=10, period=20)
|
|
13
|
+
failed, factors = qf.calculate_all_factors(pool)
|
|
14
|
+
|
|
15
|
+
fwd = qf.forward_return(data.close, periods=[1, 5, 20])
|
|
16
|
+
cs_ic = qf.CS_Information_Correlation(factors, fwd, output_path="cs_ic.parquet")
|
|
17
|
+
report = qf.multiple_testing(qf.newey_west_summary(cs_ic))
|
|
18
|
+
|
|
19
|
+
Modules:
|
|
20
|
+
datareader MarketData container, DataSource / ConstituentsSource
|
|
21
|
+
protocols and their file/yfinance implementations.
|
|
22
|
+
factor_register Decorator registry and dependency-resolving batch
|
|
23
|
+
computation of factors.
|
|
24
|
+
factor_mining Built-in factor implementations (auto-registered).
|
|
25
|
+
ic_calculator IC testing, Newey-West, multiple-testing control,
|
|
26
|
+
orthogonalization, train/test workflow.
|
|
27
|
+
back_testing Point-in-time quantile backtest, turnover costs,
|
|
28
|
+
sanity tests.
|
|
29
|
+
factor_attribution Carhart four-factor attribution (daily, HAC).
|
|
30
|
+
config/load_config Per-step dataclass configs, YAML loader
|
|
31
|
+
(see config.example.yaml).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__version__ = "0.2.0"
|
|
35
|
+
|
|
36
|
+
from .datareader import (
|
|
37
|
+
MarketData,
|
|
38
|
+
DataSource,
|
|
39
|
+
ParquetSource,
|
|
40
|
+
CSVSource,
|
|
41
|
+
ExcelSource,
|
|
42
|
+
YFinanceSource,
|
|
43
|
+
ConstituentsSource,
|
|
44
|
+
StaticUniverse,
|
|
45
|
+
MembershipTableSource,
|
|
46
|
+
)
|
|
47
|
+
from .factor_register import (
|
|
48
|
+
factor_register,
|
|
49
|
+
build_param_pool,
|
|
50
|
+
calculate_all_factors,
|
|
51
|
+
FACTOR_REGISTRY,
|
|
52
|
+
)
|
|
53
|
+
from . import factor_mining # noqa: F401 导入即注册内置因子
|
|
54
|
+
from .ic_calculator import (
|
|
55
|
+
forward_return,
|
|
56
|
+
data_standarization,
|
|
57
|
+
CS_Information_Correlation,
|
|
58
|
+
TM_Information_correlation,
|
|
59
|
+
summary,
|
|
60
|
+
resample_summary,
|
|
61
|
+
newey_west_summary,
|
|
62
|
+
multiple_testing,
|
|
63
|
+
orthogonal_analysis,
|
|
64
|
+
orthogonalize,
|
|
65
|
+
time_series_stationary_test,
|
|
66
|
+
train_test_analysis,
|
|
67
|
+
)
|
|
68
|
+
from .back_testing import (
|
|
69
|
+
quantile_backtest,
|
|
70
|
+
expand_to_daily_returns,
|
|
71
|
+
expand_all_to_daily_returns,
|
|
72
|
+
calculate_turnover,
|
|
73
|
+
apply_transcation_cost,
|
|
74
|
+
performance_summary,
|
|
75
|
+
monotonicity_test,
|
|
76
|
+
back_test_senity_test,
|
|
77
|
+
)
|
|
78
|
+
from .factor_attribution import load_french_factors, carhart_attribution
|
|
79
|
+
from .load_config import load_configs
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"MarketData", "DataSource", "ParquetSource", "CSVSource", "ExcelSource",
|
|
83
|
+
"YFinanceSource", "ConstituentsSource", "StaticUniverse",
|
|
84
|
+
"MembershipTableSource",
|
|
85
|
+
"factor_register", "build_param_pool", "calculate_all_factors",
|
|
86
|
+
"FACTOR_REGISTRY",
|
|
87
|
+
"forward_return", "data_standarization", "CS_Information_Correlation",
|
|
88
|
+
"TM_Information_correlation", "summary", "resample_summary",
|
|
89
|
+
"newey_west_summary", "multiple_testing", "orthogonal_analysis",
|
|
90
|
+
"orthogonalize", "time_series_stationary_test", "train_test_analysis",
|
|
91
|
+
"quantile_backtest", "expand_to_daily_returns",
|
|
92
|
+
"expand_all_to_daily_returns", "calculate_turnover",
|
|
93
|
+
"apply_transcation_cost", "performance_summary", "monotonicity_test",
|
|
94
|
+
"back_test_senity_test",
|
|
95
|
+
"load_french_factors", "carhart_attribution",
|
|
96
|
+
"load_configs",
|
|
97
|
+
]
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from .datareader import ConstituentsSource, MembershipTableSource
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.stats import spearmanr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def truncate_quantile(cross_section_series: list|pd.Series, part:int =5):
|
|
8
|
+
"""Split a sorted cross-sectional series into quantile buckets.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
cross_section_series: A 1D sorted sequence of tickers or values.
|
|
12
|
+
The input must already be sorted in ascending order before calling.
|
|
13
|
+
part: Number of groups to split into. Default is 5.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A list of length ``part``. Each element is a slice of the input series.
|
|
17
|
+
|
|
18
|
+
Notes:
|
|
19
|
+
This function does not sort the data. It only slices by position, so
|
|
20
|
+
the input order determines the quantile grouping.
|
|
21
|
+
"""
|
|
22
|
+
length = len(cross_section_series)
|
|
23
|
+
batch_size = length // part
|
|
24
|
+
batch = []
|
|
25
|
+
for i in range(part):
|
|
26
|
+
if i == part-1:
|
|
27
|
+
quantile = cross_section_series[i*batch_size:length]
|
|
28
|
+
else:
|
|
29
|
+
quantile = cross_section_series[i*batch_size: (i+1)*batch_size]
|
|
30
|
+
batch.append(quantile)
|
|
31
|
+
return list(batch)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def quantile_backtest(constituents: ConstituentsSource | pd.DataFrame | None ,factors: dict[str, pd.DataFrame], significant_factor_list:list, forward_returns: dict[int,pd.DataFrame], part:int =5):
|
|
35
|
+
"""Run a quantile long-short backtest for each selected factor.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
constituents: Point-in-time universe source implementing
|
|
39
|
+
``get_constituents(date) -> set[str]``. A raw membership dataframe
|
|
40
|
+
(ticker/start_date/end_date columns) is auto-wrapped in
|
|
41
|
+
``MembershipTableSource``. Pass ``None`` to use every factor
|
|
42
|
+
column (survivorship-biased fixed universe).
|
|
43
|
+
factors: Mapping of factor name to a date-by-ticker value dataframe.
|
|
44
|
+
significant_factor_list: Factor names to evaluate.
|
|
45
|
+
forward_returns: Mapping of holding period (in trading days) to a
|
|
46
|
+
date-by-ticker forward return dataframe.
|
|
47
|
+
part: Number of quantile groups to form. Default is 5.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A tuple ``(all_result, all_ticker_history)``. Both are keyed by
|
|
51
|
+
``(factor_name, period)``; results hold Q1..Qn plus ``long_short``
|
|
52
|
+
returns per rebalance date, histories hold the member set per group.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(constituents, pd.DataFrame):
|
|
55
|
+
constituents = MembershipTableSource(constituents)
|
|
56
|
+
|
|
57
|
+
all_result={}
|
|
58
|
+
all_ticker_history = {}
|
|
59
|
+
|
|
60
|
+
for significant_factor in significant_factor_list:
|
|
61
|
+
factor_df = factors[significant_factor]
|
|
62
|
+
for period, forward_return_df in forward_returns.items():
|
|
63
|
+
result=[]
|
|
64
|
+
ticker_history = []
|
|
65
|
+
for index in range(0, len(factor_df), period):
|
|
66
|
+
curr_date = factor_df.index[index]
|
|
67
|
+
if curr_date not in forward_return_df.index:
|
|
68
|
+
continue
|
|
69
|
+
if constituents is None:
|
|
70
|
+
available_tickers = list(factor_df.columns)
|
|
71
|
+
else:
|
|
72
|
+
valid_tickers = constituents.get_constituents(curr_date)
|
|
73
|
+
#按factor列序取交集: valid_tickers是set, 直接遍历顺序不确定,
|
|
74
|
+
#并列排名经稳定排序后分位边界成员会随运行漂移, 结果不可复现
|
|
75
|
+
available_tickers = [t for t in factor_df.columns if t in valid_tickers]
|
|
76
|
+
|
|
77
|
+
if factor_df.iloc[index].isnull().all():
|
|
78
|
+
continue
|
|
79
|
+
cross_section_factor = factor_df.loc[curr_date, available_tickers]
|
|
80
|
+
ranked_cross_section = cross_section_factor.rank(ascending=True)
|
|
81
|
+
ranked_cross_section = ranked_cross_section.sort_values()
|
|
82
|
+
tickers = ranked_cross_section.index
|
|
83
|
+
|
|
84
|
+
group_list = truncate_quantile(tickers ,part = part)
|
|
85
|
+
|
|
86
|
+
_return = {'date':curr_date}
|
|
87
|
+
_tickers = {'date':curr_date}
|
|
88
|
+
for i in range(part):
|
|
89
|
+
group_return = forward_return_df.loc[curr_date, group_list[i]]
|
|
90
|
+
_return[f'Q{i+1}'] = group_return.mean()
|
|
91
|
+
_tickers[f'Q{i+1}'] = set(group_list[i])
|
|
92
|
+
ticker_history.append(_tickers)
|
|
93
|
+
result.append(_return)
|
|
94
|
+
result_df = pd.DataFrame(result).set_index('date')
|
|
95
|
+
result_df['long_short'] = result_df['Q5']-result_df['Q1']
|
|
96
|
+
all_ticker_history[(significant_factor,period)] = ticker_history
|
|
97
|
+
all_result[(significant_factor,period)] = result_df
|
|
98
|
+
return all_result ,all_ticker_history
|
|
99
|
+
|
|
100
|
+
def expand_to_daily_returns(tickers_history:list, close_data: pd.DataFrame, cost_per_trade: float = 0.001):
|
|
101
|
+
"""Expand periodic rebalance snapshots into a net-of-cost daily return series.
|
|
102
|
+
|
|
103
|
+
Between two rebalance dates the portfolio holds the quantile members
|
|
104
|
+
fixed, so each daily return is the equal-weighted mean of member returns.
|
|
105
|
+
Transaction costs are charged on the first trading day after each
|
|
106
|
+
rebalance, scaled by the actual membership turnover of that quantile.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tickers_history: List of dicts produced by ``quantile_backtest``. Each
|
|
110
|
+
item holds the rebalance ``date`` and the member set per quantile.
|
|
111
|
+
close_data: Close price dataframe covering all member tickers.
|
|
112
|
+
cost_per_trade: One-way cost per unit of turnover. The deduction is
|
|
113
|
+
``turnover * 2 * cost_per_trade`` to cover both buys and sells.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A wide dataframe of daily returns with columns ``Q1``..``Q5`` and a
|
|
117
|
+
``long_short`` spread column.
|
|
118
|
+
|
|
119
|
+
Notes:
|
|
120
|
+
The price window must include the rebalance date itself: ``pct_change``
|
|
121
|
+
makes the window's first row NaN, and that row is dropped. Without the
|
|
122
|
+
rebalance-day anchor, each window's first daily return is lost and the
|
|
123
|
+
cost deduction lands on a NaN (i.e. costs are silently never charged).
|
|
124
|
+
"""
|
|
125
|
+
daily_records = []
|
|
126
|
+
for i in range(len(tickers_history)-1):
|
|
127
|
+
curr = tickers_history[i]
|
|
128
|
+
next_date = tickers_history[i+1]['date']
|
|
129
|
+
curr_date = curr['date']
|
|
130
|
+
|
|
131
|
+
#窗口含起点(调仓日): pct_change后调仓日行为NaN被丢弃, 调仓日->次日的收益保留,
|
|
132
|
+
#窗口右端含next_date(归属旧组合), 下一窗口从next_date+1天起, 不重不漏
|
|
133
|
+
window_dates = close_data.index[(close_data.index >= curr_date) & (close_data.index <= next_date)]
|
|
134
|
+
for q in [f'Q{n}' for n in range(1,6)]:
|
|
135
|
+
tickers_in_group = list(curr[q])
|
|
136
|
+
group_price = close_data.loc[window_dates, tickers_in_group]
|
|
137
|
+
portfolio_daily_return = group_price.pct_change().mean(axis=1).iloc[1:]
|
|
138
|
+
|
|
139
|
+
if i>0:
|
|
140
|
+
prev_set = set(tickers_history[i-1][q])
|
|
141
|
+
curr_set = curr[q]
|
|
142
|
+
overlap = len(prev_set & curr_set)
|
|
143
|
+
turnover = 1 - overlap/len(curr_set)
|
|
144
|
+
else:
|
|
145
|
+
turnover = 1.0 #初始建仓视为全额换手
|
|
146
|
+
|
|
147
|
+
cost_today = turnover *2 *cost_per_trade
|
|
148
|
+
|
|
149
|
+
for j, (d, r) in enumerate(portfolio_daily_return.items()):
|
|
150
|
+
if j == 0:
|
|
151
|
+
r = r - cost_today
|
|
152
|
+
daily_records.append({'date':d, 'group':q, 'return':r})
|
|
153
|
+
daily_long = pd.DataFrame(daily_records)
|
|
154
|
+
daily_wide = daily_long.pivot(index='date', columns='group', values='return')
|
|
155
|
+
daily_wide['long_short'] = daily_wide['Q5'] - daily_wide['Q1']
|
|
156
|
+
return daily_wide
|
|
157
|
+
|
|
158
|
+
def expand_all_to_daily_returns(
|
|
159
|
+
all_ticker_history: dict,
|
|
160
|
+
close_data: pd.DataFrame,
|
|
161
|
+
cost_per_trade: float = 0.001,
|
|
162
|
+
) -> dict:
|
|
163
|
+
"""
|
|
164
|
+
对 quantile_backtest 产出的 all_ticker_history(key是(factor, period)元组),
|
|
165
|
+
批量展开成逐日收益,不需要手动一个个取。
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
dict[(factor, period), pd.DataFrame] —— 和输入结构对应,
|
|
169
|
+
每个key对应一份展开后的逐日Q1-Q5+long_short收益表
|
|
170
|
+
"""
|
|
171
|
+
all_daily_returns = {}
|
|
172
|
+
for key, ticker_history in all_ticker_history.items():
|
|
173
|
+
all_daily_returns[key] = expand_to_daily_returns(ticker_history, close_data, cost_per_trade)
|
|
174
|
+
return all_daily_returns
|
|
175
|
+
|
|
176
|
+
def calculate_turnover(ticker_history: list, group: str)->pd.Series:
|
|
177
|
+
"""Compute per-rebalance membership turnover for one quantile group.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
ticker_history: List of dicts produced by ``quantile_backtest``.
|
|
181
|
+
group: Quantile column name, e.g. ``"Q1"`` or ``"Q5"``.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A series aligned with the rebalance sequence. The first element is NaN
|
|
185
|
+
because turnover is undefined before the initial holdings exist.
|
|
186
|
+
|
|
187
|
+
Notes:
|
|
188
|
+
Turnover is ``1 - |prev ∩ curr| / |curr|``, the fraction of the current
|
|
189
|
+
portfolio that had to be newly bought at this rebalance.
|
|
190
|
+
"""
|
|
191
|
+
turnovers = [np.nan]
|
|
192
|
+
for i in range(1, len(ticker_history)):
|
|
193
|
+
prev_set = ticker_history[i-1][group]
|
|
194
|
+
curr_set = ticker_history[i][group]
|
|
195
|
+
overlap = len(prev_set & curr_set)
|
|
196
|
+
turnover = 1-(overlap/len(curr_set))
|
|
197
|
+
turnovers.append(turnover)
|
|
198
|
+
return pd.Series(turnovers)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def back_test_senity_test(constituents: ConstituentsSource | pd.DataFrame | None ,significant_factor_list:list, factors: dict[str,pd.DataFrame] ,forward_returns: dict[int,pd.DataFrame], close:pd.DataFrame, periods:list,
|
|
202
|
+
origincal_back_test: dict):
|
|
203
|
+
"""Run sensitivity tests against factor displacement and shuffled factors.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
significant_factor_list: Factor names to evaluate.
|
|
207
|
+
factor_ticker: Original factor table indexed by date.
|
|
208
|
+
diff_holding_period: Precomputed holding-period return table.
|
|
209
|
+
close: Close price table used to recompute forward returns.
|
|
210
|
+
periods: List of holding periods in trading days.
|
|
211
|
+
origincal_back_test: Baseline quantile backtest results used as the
|
|
212
|
+
comparison reference.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A tuple of ``(total_difference, displace_difference, shuffle_difference)``.
|
|
216
|
+
|
|
217
|
+
Notes:
|
|
218
|
+
This function compares the baseline backtest against two perturbations:
|
|
219
|
+
shifting factor values by one period and randomly shuffling factor rows.
|
|
220
|
+
"""
|
|
221
|
+
tickers = list(close.columns)
|
|
222
|
+
factors_shifting = {}
|
|
223
|
+
for factor_name, factor in factors.items():
|
|
224
|
+
factors_shifting[factor_name] = factor.shift(-1)
|
|
225
|
+
#shuffle基于原始因子, 且必须逐frame深拷贝:
|
|
226
|
+
#dict.copy()是浅拷贝, in-place打乱会把factors_shifting的frame一起改掉
|
|
227
|
+
shuffle_data = {factor_name: factor.copy() for factor_name, factor in factors.items()}
|
|
228
|
+
for factor_name, factor in shuffle_data.items():
|
|
229
|
+
for idx in factor.index:
|
|
230
|
+
factor.loc[idx] = np.random.permutation(factor.loc[idx].values)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
period_difference = {}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
for period in periods:
|
|
238
|
+
#holding period return and close data shifting difference
|
|
239
|
+
|
|
240
|
+
raw_return = pd.DataFrame({ticker : close[ticker].pct_change(period).shift(-period) for ticker in tickers})
|
|
241
|
+
common_col = forward_returns[period].columns.intersection(raw_return.columns)
|
|
242
|
+
diff = (forward_returns[period][common_col]-raw_return[common_col]).abs().sum().sum() #第一个sum得到series,第二个sum把所有series相加得到标量
|
|
243
|
+
period_difference[period] = diff
|
|
244
|
+
|
|
245
|
+
#factor displacement
|
|
246
|
+
factor_displacement_result , _ = quantile_backtest(constituents, factors_shifting,significant_factor_list, forward_returns)
|
|
247
|
+
|
|
248
|
+
#shuffle data
|
|
249
|
+
shuffle_data_return, _ = quantile_backtest(constituents,shuffle_data, significant_factor_list, forward_returns)
|
|
250
|
+
|
|
251
|
+
total_difference = 0
|
|
252
|
+
for period, diff in period_difference.items():
|
|
253
|
+
total_difference += diff
|
|
254
|
+
|
|
255
|
+
displace_difference = {}
|
|
256
|
+
for (factor_name, period), df in factor_displacement_result.items():
|
|
257
|
+
displace_difference[(factor_name,period)] = df - origincal_back_test[(factor_name, period)]
|
|
258
|
+
|
|
259
|
+
shuffle_difference = {}
|
|
260
|
+
for (factor_name, period), df in shuffle_data_return.items():
|
|
261
|
+
shuffle_difference[(factor_name,period)] = df - origincal_back_test[(factor_name, period)]
|
|
262
|
+
|
|
263
|
+
for key, diff in displace_difference.items():
|
|
264
|
+
long_short_diff = diff['Q5'] - diff['Q1']
|
|
265
|
+
print(f'{key}: displace factor increase SHAP ratio:{long_short_diff.mean()/long_short_diff.std():.4f}')
|
|
266
|
+
|
|
267
|
+
for key, diff in shuffle_difference.items():
|
|
268
|
+
long_short_diff = diff['Q5'] - diff['Q1']
|
|
269
|
+
print(f'{key}: displace factor increase SHAP ratio:{long_short_diff.mean()/long_short_diff.std():.4f}')
|
|
270
|
+
|
|
271
|
+
return total_difference, displace_difference, shuffle_difference
|
|
272
|
+
|
|
273
|
+
def performance_summary(back_test_quantile:pd.DataFrame, periods:int):
|
|
274
|
+
"""Summarize quantile backtest performance.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
back_test_quantile: Dataframe of periodic returns for each quantile.
|
|
278
|
+
periods: Holding period in trading days used to annualize metrics.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
A tuple of ``(summary_dataframe, net_return_series)`` where the
|
|
282
|
+
dataframe contains annualized return, volatility, Sharpe ratio, max
|
|
283
|
+
drawdown, and win rate.
|
|
284
|
+
|
|
285
|
+
Notes:
|
|
286
|
+
The input should contain return series, not cumulative returns.
|
|
287
|
+
"""
|
|
288
|
+
result = []
|
|
289
|
+
for col in back_test_quantile.columns:
|
|
290
|
+
r = back_test_quantile[col].dropna()
|
|
291
|
+
net_return = (1+r).cumprod()
|
|
292
|
+
|
|
293
|
+
total_return = net_return.iloc[-1] - 1
|
|
294
|
+
n_years = len(r) * periods / 252
|
|
295
|
+
|
|
296
|
+
if net_return.iloc[-1] > 0:
|
|
297
|
+
yearly_return = net_return.iloc[-1]**(1/n_years)-1
|
|
298
|
+
else:
|
|
299
|
+
yearly_return = np.nan
|
|
300
|
+
volatility = r.std() * np.sqrt(252/periods)
|
|
301
|
+
sharp = yearly_return / volatility if (volatility != 0 and not np.isnan(yearly_return)) else np.nan
|
|
302
|
+
|
|
303
|
+
rolling_max = net_return.cummax()
|
|
304
|
+
drawdown = (net_return - rolling_max) / rolling_max
|
|
305
|
+
max_drawdown = drawdown.min()
|
|
306
|
+
|
|
307
|
+
win_rate = (r>0).mean()
|
|
308
|
+
summary = {
|
|
309
|
+
'quantile': col,
|
|
310
|
+
'yearly_return': yearly_return,
|
|
311
|
+
'volatility' :volatility,
|
|
312
|
+
'sharp_ratio': sharp,
|
|
313
|
+
'max_drawdown':max_drawdown,
|
|
314
|
+
'win_rate': win_rate
|
|
315
|
+
}
|
|
316
|
+
result.append(summary)
|
|
317
|
+
return pd.DataFrame(result).set_index('quantile'), net_return
|
|
318
|
+
|
|
319
|
+
def monotonicity_test(result_df: pd.DataFrame, part: int = 5)->dict:
|
|
320
|
+
"""Test whether quantile returns are monotonically increasing.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
result_df: Dataframe containing quantile columns named ``Q1`` to
|
|
324
|
+
``Qn``.
|
|
325
|
+
n_group: Number of quantile groups to inspect. Default is 5.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
A dictionary with the mean-based Spearman correlation, its p-value,
|
|
329
|
+
the average daily correlation, and the fraction of positive daily
|
|
330
|
+
correlations.
|
|
331
|
+
|
|
332
|
+
Notes:
|
|
333
|
+
Rows with missing quantile values are skipped in the daily test.
|
|
334
|
+
"""
|
|
335
|
+
quantile_cols = [f'Q{i}' for i in range(1,part+1)]
|
|
336
|
+
ranks = list(range(1, part+1))
|
|
337
|
+
means = result_df[quantile_cols].mean()
|
|
338
|
+
corr_simple, pval_simple = spearmanr(ranks, means.values)
|
|
339
|
+
|
|
340
|
+
daily_corrs = []
|
|
341
|
+
for _, row in result_df[quantile_cols].iterrows():
|
|
342
|
+
if row.isnull().any():
|
|
343
|
+
continue
|
|
344
|
+
c, _ = spearmanr(ranks, row.values)
|
|
345
|
+
daily_corrs.append(c)
|
|
346
|
+
return {'mean_based_corr': corr_simple,
|
|
347
|
+
'mean_based_pvalue': pval_simple,
|
|
348
|
+
'daily_avg_corr': pd.Series(daily_corrs).mean(),
|
|
349
|
+
'daily_corr_positive_pct':(pd.Series(daily_corrs)>0).mean()}
|
|
350
|
+
|
|
351
|
+
def apply_transcation_cost(result_df: pd.DataFrame, ticker_history: list,cost_per_trade: float =0.001)-> pd.DataFrame:
|
|
352
|
+
"""Apply turnover-based transaction costs to quantile returns.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
result_df: Quantile return dataframe produced by the backtest.
|
|
356
|
+
ticker_history: Per-rebalance member sets from ``quantile_backtest``,
|
|
357
|
+
used to compute the actual turnover of each group.
|
|
358
|
+
cost_per_trade: One-way cost per unit of turnover. Default is 0.001.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
A new dataframe with transaction costs deducted from each quantile and
|
|
362
|
+
from the ``long_short`` spread.
|
|
363
|
+
|
|
364
|
+
Notes:
|
|
365
|
+
Each group's charge is ``turnover * 2 * cost_per_trade`` (buys plus
|
|
366
|
+
sells). The spread column is charged for both legs, so its turnover is
|
|
367
|
+
the sum of the Q1 and Q5 turnovers.
|
|
368
|
+
"""
|
|
369
|
+
turnover_q1 = calculate_turnover(ticker_history=ticker_history, group='Q1')
|
|
370
|
+
turnover_q5 = calculate_turnover(ticker_history=ticker_history, group='Q5')
|
|
371
|
+
result_after_cost = result_df.copy()
|
|
372
|
+
for col in result_df.columns:
|
|
373
|
+
|
|
374
|
+
if col == 'long_short':
|
|
375
|
+
cost = (turnover_q5.values + turnover_q1.values)*2*cost_per_trade
|
|
376
|
+
result_after_cost[col] = result_df[col].values - cost
|
|
377
|
+
else:
|
|
378
|
+
turnover = calculate_turnover(ticker_history=ticker_history, group = col)
|
|
379
|
+
cost = turnover.values * 2 * cost_per_trade
|
|
380
|
+
result_after_cost[col] = result_df[col].values - cost
|
|
381
|
+
return result_after_cost
|
quantmine/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class BaseCheckpointConfig:
|
|
6
|
+
checkpoint_dir: str = 'tmp/checkpoint'
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DataAcquisitionConfig(BaseCheckpointConfig):
|
|
10
|
+
max_retries: int = 3
|
|
11
|
+
wait: int = 60
|
|
12
|
+
def __post_init__(self):
|
|
13
|
+
if not (0 < self.max_retries < 100):
|
|
14
|
+
raise ValueError(f'max retries only support retreies below 100 times, currently{self.max_retries}')
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RetryBatchesConfig(BaseCheckpointConfig):
|
|
18
|
+
wait: int = 60
|
|
19
|
+
|
|
20
|
+
'''
|
|
21
|
+
factor_mining
|
|
22
|
+
'''
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MomentumConfig:
|
|
26
|
+
day: int = 5
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.day< 1:
|
|
29
|
+
raise ValueError(f"momentum days greater than one, current{self.day}")
|
|
30
|
+
|
|
31
|
+
'''
|
|
32
|
+
ic_calculator
|
|
33
|
+
'''
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ForwardReturnConfig:
|
|
37
|
+
periods: list[int]|int = field(default_factory= lambda: [1,5,20])
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
if isinstance(self.periods, int): #类型声明接受int, 不归一化的话下面的for会对int迭代报错
|
|
40
|
+
self.periods = [self.periods]
|
|
41
|
+
for period in self.periods:
|
|
42
|
+
if not isinstance(period, int):
|
|
43
|
+
raise ValueError(f'periods should be integer')
|
|
44
|
+
if period < 1:
|
|
45
|
+
raise ValueError(f'periods should greater than 1, current{self.periods}')
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NeweyWestSummaryConfig:
|
|
49
|
+
lag_multiplier: int = 2
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if self.lag_multiplier< 1:
|
|
52
|
+
raise ValueError(f"lag multiplier should greater than one, current{self.lag_multiplier}")
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class OrthogonalizeConfig:
|
|
56
|
+
threshold: float = 0.03
|
|
57
|
+
min_period : int = 60
|
|
58
|
+
def __post_init__(self):
|
|
59
|
+
if not (0 < self.threshold < 1):
|
|
60
|
+
raise ValueError(f"threshold should be between 0, 1, current: {self.threshold}")
|
|
61
|
+
if self.min_period< 1:
|
|
62
|
+
raise ValueError(f"min period should greater than one, current{self.min_period}")
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class TimeSeriesStationaryTestConfig:
|
|
66
|
+
rolling_period: int = 126
|
|
67
|
+
periods: list = field(default_factory=lambda: [1, 5, 20])
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if self.rolling_period < 2:
|
|
70
|
+
raise ValueError(f"rolling period should greater than one, current{self.rolling_period}")
|
|
71
|
+
for period in self.periods:
|
|
72
|
+
if not isinstance(period, int) or period < 1:
|
|
73
|
+
raise ValueError(f'periods should be positive integers, current{self.periods}')
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class BackTestingConfig:
|
|
77
|
+
part: int = 5
|
|
78
|
+
def __post_init__(self):
|
|
79
|
+
if self.part < 2:
|
|
80
|
+
raise ValueError(f"part should greater than two, current{self.part}")
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class TranscationCostConfig:
|
|
84
|
+
cost_per_trade: float = 0.001
|
|
85
|
+
def __post_init__(self):
|
|
86
|
+
if not (0 < self.cost_per_trade < 1):
|
|
87
|
+
raise ValueError(f"cost per trade should in (0,1), current{self.cost_per_trade}")
|
|
88
|
+
|
|
89
|
+
'''
|
|
90
|
+
factor attribution
|
|
91
|
+
'''
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CarhartAttributionConfig:
|
|
95
|
+
maxlags: int = 20
|
|
96
|
+
def __post_init__(self):
|
|
97
|
+
if self.maxlags < 1:
|
|
98
|
+
raise ValueError(f"max lags should greater than one, current{self.maxlags}")
|
|
99
|
+
|
|
100
|
+
CONFIG_REGISTRY = {
|
|
101
|
+
'checkpoint': BaseCheckpointConfig,
|
|
102
|
+
'data_acquisition': DataAcquisitionConfig,
|
|
103
|
+
'retry_batches': RetryBatchesConfig,
|
|
104
|
+
|
|
105
|
+
'momentum': MomentumConfig,
|
|
106
|
+
'forward_return': ForwardReturnConfig,
|
|
107
|
+
'newey_west': NeweyWestSummaryConfig,
|
|
108
|
+
'orthogonalize': OrthogonalizeConfig,
|
|
109
|
+
'time_series_stationary_test': TimeSeriesStationaryTestConfig,
|
|
110
|
+
|
|
111
|
+
'backtest': BackTestingConfig,
|
|
112
|
+
'transaction_cost': TranscationCostConfig,
|
|
113
|
+
|
|
114
|
+
'carhart_attribution': CarhartAttributionConfig,
|
|
115
|
+
}
|