Open nictra23 opened 1 year ago
Market Capitalization, PE, PB, PS: We selected the time period from August 2021 to April 2023 for backtesting. Here, we are using a four-factor strategy for stock selection, consisting of PE, PB, PS, and market capitalization. We then backtest in the factor ranking intervals, choosing ranges of 10%, 30%, up to 90%. All stocks within each interval are added to the stock pool for testing. At the same time, we backtest at different trading frequencies, daily and monthly. Monthly tests are conducted as follows:
Once per day:
As the graph shows, we have compared the strategy's daily and monthly trading returns (the strategy runs once a day for daily trading and once a month for monthly trading). By comparing the interval returns, we can see that the trend of returns under different trading frequencies is similar. The daily trend is smoother with less fluctuation after the 40% mark, but other metrics like risk indices and Sharpe ratios are similar for the two different trading frequencies. To reduce running time, we decide to proceed with a monthly trading frequency for subsequent experiments.
By observing the trend in returns, the strategy doesn't show a significant directional trend; it is more towards a normal distribution that is left-skewed. If further unidirectional analysis is required, it would need to be discussed across different industries. Overall, however, this strategy is far superior to the baseline strategy, with higher returns and both Sharpe and Sortino ratios greater than 0.1, indicating that the strategy has a certain ability to profit from risk volatility.
We will now begin to decompose the multi-factor model and gradually analyze the combinations of factors that affect returns.
PEPBPS: Similarly, we select the time period from August 2021 to April 2023 for backtesting. Here we use a three-factor strategy for stock selection, focusing on PE, PB, and PS ratios. We then perform interval backtesting based on the overall ranking of factors (Here, we've optimized the model to increase the selection range, allowing more high-quality stocks to enter the stock pool). The intervals we select are 10%, 20%... up to 90% (We have also refined the backtesting intervals, which better reflects the trends and sensitivities of returns across different ranges). Stocks from each range are added to the stock pool for testing.
the three-factor strategy has better uni-directional characteristics and boasts an average return that's around 10% higher than the four-factor strategy within the timeframe you tested.
Uni-directional Characteristics: The three-factor strategy exhibits strong uni-directional traits, meaning that higher-ranked (quality) stocks outperform lower-ranked stocks. This reduces the need for additional layers of analysis, such as industry-specific segmentation.
Risk and Returns: While the three-factor strategy does exhibit a maximum drawdown of 21%, its downside risk appears to be relatively low, as evidenced by a high Sortino ratio. This suggests that the strategy can achieve higher returns even when taking on higher risk.
Volatility: Although the three-factor strategy outperforms the four-factor strategy in terms of average returns and risk-adjusted returns, it is also more volatile.
Selection Intervals: In the three-factor strategy, the top 30% ranking interval appears to be a low-risk and relatively stable return segment.
PEPB: We selected the time period from August 2021 to April 2023 for backtesting. From our previous three-factor and four-factor strategies, we found that neither showed significant unidirectional trends in returns, and both had higher risks compared to single-factor strategies. Therefore, we further refined our approach. Here we used a two-factor strategy for stock selection, focusing on the PE and PB factors. We then backtested the strategy on all stocks within the ranking intervals of 10%, 20%, up to 90%.
As shown in the chart, the average return for this combination is 25%. There is some unidirectionality, but the trend appears to follow a normal distribution, indicating that further industry-specific analysis is needed. Although the average maximum drawdown is at 16%, the strategy's risk at the top end is 0.015 less than the benchmark market and decreases, suggesting that while its returns may not match the four-factor strategy, its risk is lower and its returns are higher than the benchmark. The Sortino ratio across all intervals is greater than 0 and averages at 0.45, indicating that the combination performs well in terms of downside risk. Overall, compared to the four-factor strategy, the PEPB strategy is somewhat superior as it offers lower risk and steadily improving returns.
PBPS: We selected the time period from August 2021 to April 2023 for backtesting. From our previous three-factor and four-factor strategies, we found that neither showed significant unidirectional trends in returns, and both had higher risks than single-factor strategies. Therefore, we further refined our strategy. Here we employed a two-factor strategy for stock selection, specifically focusing on the PS and PB factors. Then, we backtested all stocks within the ranking intervals of 10%, 20%, up to 90%. Each interval includes all stocks in the pool for testing.
As shown in the chart, the trend in returns for this strategy clearly follows a unidirectional pattern, declining as the selection range increases, which suggests there's no need for further industry-specific analysis. The stocks that rank higher in terms of these factors have better returns than those that rank lower, which aligns with our expectations. Overall, the PBPS combination is very effective; the top 10% yields a return of 31%, which is more stable compared to PEPB, although there is a significant jump between 40%-50% (indicating the presence of additional information, perhaps representing a particular industry). The trends for both the Sharpe and Sortino ratios are similar, showing a high-to-low unidirectional trend, signifying that the strategy's downside risk returns and overall risk-adjusted returns are both above 0.5. This suggests that the risk-return performance of this strategy is quite good.
Summary:
In summary, within this time frame, multi-factor strategies have enhanced the unidirectionality of the strategy, with the PBPE two-factor strategy reaching its peak. However, as the number of factors increases, the trend in returns gradually becomes a normal distribution. This implies that additional information starts to appear in the strategy as more factors are included. This additional information impacts the returns, necessitating sector-specific analysis.
Moreover, we find that single-factor strategies, such as those based on PB or PS, actually yield higher returns than multi-factor combinations and carry lower risks. However, they lack unidirectionality and contain additional information. But if we are to pursue a multi-factor combination, the PBPS factor combination for selecting the stock pool turns out to be the optimal solution. Not only does it perfectly conform to a unidirectional trend, but it also yields as high as 30% at the top end, and carries the lowest risk. Therefore, we highly recommend the PBPS multi-factor strategy in this context.
PEPBPS Market Cap - Quality Factor: We selected the time period from August 2021 to April 2023 for backtesting. Here we used a four-factor + quality factor strategy for stock selection, namely the PEPBPS market cap factor and the quality factor. Within this, we employed a four-factor strategy for stock selection, namely the PSPB market cap PS factor. Subsequently, the factors were ranked and divided into intervals of 10%, 20%... up to 90%. In each interval, all stocks were included in the selection pool for testing purposes.
By observing the backtested returns from the value taken, and by analyzing the trend of returns, it is evident that this strategy lacks a significant directional trend and exhibits relatively weak one-sidedness (leaning more towards a left-skewed normal distribution). If a further analysis of the one-sidedness is necessary, it would require a discussion on a sector-by-sector basis. However, overall, this strategy is much better than the baseline strategy, with returns arranged from largest to smallest. This is in comparison to the strategy mentioned above that did not include the quality factor.
We can observe that this strategy has generally reduced the original returns, but there is not a significant difference in the specific trends. However, after incorporating the quality factor, the returns of the selected stocks within the first 40% interval stand out more prominently. This is because there is a substantial jump after the 50% mark and a consistent decline thereafter. This to some extent validates why we choose to conduct a detailed analysis on the stocks within the first 40% interval.
PSPB - Quality Factor: We conducted backtesting during the period from August 2021 to April 2023. For the previous three-factor and four-factor strategies, we observed that there was no clear one-sided trend in returns, and the risks were higher compared to the single-factor strategy. This led us to further refinement. In this case, we employed a PBPS dual-factor strategy for stock selection, specifically the PSPB factor. Following this, upon the basis of the top 40% ranking by the total number of factors, we re-sorted the stocks based on the GP_ratio and purchased them in descending order. Subsequently, interval-based backtesting was carried out. We selected intervals of 10%, 20%... up to 90%. In each interval, the top ten ranked stocks were included in the selection pool for testing purposes.
As shown in the graph, we have added the quality factor combination to the PSPB factor. From the returns, it's evident that this has intensified the volatility of stock selection returns. However, from the combination of the three approaches mentioned above with the addition of the quality factor, our top-performing stock selection has not undergone significant changes, maintaining the same 30% return-risk ratio. The new strategy's trend remains similar to the original strategy, but the risk has shifted from a unidirectional downward trend to a convex one, indicating that as the returns decrease, the risk is gradually increasing. As shown in the graph, we have added the quality factor combination to the PSPB factor. From the returns, it's evident that this has intensified the volatility of stock selection returns. However, from the combination of the three approaches mentioned above with the addition of the quality factor, our top-performing stock selection has not undergone significant changes, maintaining the same 30% return-risk ratio. The new strategy's trend remains similar to the original strategy, but the risk has shifted from a unidirectional downward trend to a convex one, indicating that as the returns decrease, the risk is gradually increasing.
CODE: import numpy as np import talib import pandas import scipy as sp import scipy.optimize import datetime as dt from scipy import linalg as sla from scipy import spatial from jqdata import * import smtplib from email.mime.text import MIMEText from email.header import Header import statsmodels.api as sm
def initialize(context):
# Use CSI 300 as the benchmark
set_benchmark('000300.XSHG')
# Slippage, real prices
set_slippage(FixedSlippage(0.000))
set_option('use_real_price', True)
# Turn off some logs
log.set_level('order', 'error')
run_monthly(rebalance,1, time='9:30')
def after_code_changed(context):
g.quantlib = quantlib()
# Define risk exposure
g.quantlib.fun_set_var(context, 'riskExposure', 0.03)
# Normal distribution probability table, standard deviation multiples and confidence levels
g.quantlib.fun_set_var(context, 'confidencelevel', 1.96)
# Rebalancing parameters
g.quantlib.fun_set_var(context, 'hold_cycle', 30)
g.quantlib.fun_set_var(context, 'hold_periods', 0)
g.quantlib.fun_set_var(context, 'stock_list', [])
g.quantlib.fun_set_var(context, 'position_price', {})
g.quantlib.fun_set_var(context, 'version', 1.3)
if context.version < 1.3:
context.hold_periods = 0
context.riskExposure = 0.03
context.version = 1.3
def before_trading_start(context):
# Define stock pool
moneyfund = []
fund = []
# Exclude stocks that have been listed for less than 60 days
context.moneyfund = g.quantlib.fun_delNewShare(context, moneyfund, 60)
context.fund = g.quantlib.fun_delNewShare(context, fund, 60)
# Record pre-market returns
context.returns = {}
context.returns['algo_before_returns'] = context.portfolio.returns
def rebalance(context):
# Reference libraries
g.GP = Gross_Profitability_lib()
g.quantlib = quantlib()
context.msg = ""
# Check if rebalancing is needed
rebalance_flag, context.position_price, context.hold_periods, msg = \
g.quantlib.fun_needRebalance('GP algo ', context.moneyfund, context.stock_list, context.position_price,
context.hold_periods, context.hold_cycle, 0.25)
context.msg += msg
statsDate = context.current_dt.date()
trade_style = False
if rebalance_flag:
stock_list, bad_stock_list = [], []
GP_stock_list = g.GP.fun_get_stock_list(context, statsDate, bad_stock_list, stock_list)
stock_list = stock_list + GP_stock_list
# Allocate positions
equity_ratio, bonds_ratio = g.quantlib.fun_assetAllocationSystem(stock_list, context.moneyfund, statsDate)
risk_ratio = 0
if len(equity_ratio.keys()) >= 1:
risk_ratio = context.riskExposure / len(equity_ratio.keys())
# Allocate positions based on pre-set risk exposure
position_ratio = g.quantlib.fun_calPosition(equity_ratio, bonds_ratio, 1.0, risk_ratio, context.moneyfund,
context.portfolio.portfolio_value, context.confidencelevel,
statsDate)
trade_style = True
context.stock_list = list(position_ratio.keys())
# Update desired purchase prices
context.position_price = g.quantlib.fun_update_positions_price(position_ratio)
# Sell stocks that are in the portfolio but not in the desired purchase list
for stock in context.portfolio.positions.keys():
if stock not in position_ratio:
position_ratio[stock] = 0
context.position_ratio = position_ratio
print(position_ratio) # Modified print statement for Python 3
# Rebalance, execute trades
g.quantlib.fun_do_trade(context, context.position_ratio, context.moneyfund, trade_style)
class Gross_Profitability_lib():
def fun_get_stock_list(self, context, statsDate=None, bad_stock_list=[], candidate=[]):
df = get_fundamentals(
query(valuation.code, valuation.market_cap, valuation.pe_ratio, valuation.ps_ratio, valuation.pb_ratio)
)
df1 = df['code'].tolist()
positions_list = list(context.portfolio.positions.keys())
df1 = g.quantlib.unpaused(df1, positions_list)
df1 = g.quantlib.remove_st(df1, statsDate)
df1 = g.quantlib.remove_limit_up(df1, positions_list)
df = df[df.code.isin(df1)]
df = df.reset_index(drop=True)
set_PB = {}
set_PS = {}
set_PR = {}
good_stocks = {}
fPB = df.sort_values(by=['pb_ratio'], ascending=True)
fPB = fPB.reset_index(drop=True)
fPB = fPB[fPB.pb_ratio > 0]
fPB = fPB.reset_index(drop=True)
sListPB = fPB['code'].tolist()
for i, v in enumerate(sListPB, 1):
set_PB[v] = int(i)
fPS = df.sort_values(by=['ps_ratio'], ascending=True)
fPS = fPS.reset_index(drop=True)
sListPS = fPS['code'].tolist()
for i, v in enumerate(sListPS, 1):
set_PS[v] = int(i)
df2 = get_fundamentals(
query(income.code, income.total_operating_revenue, income.total_operating_cost, balance.total_assets),
date=statsDate - dt.timedelta(1)
)
df2 = df2.fillna(value=0)
df2 = df2[df2.total_operating_revenue > 0]
df2 = df2.reset_index(drop=True)
df2 = df2[df2.total_assets > 0]
df2 = df2.reset_index(drop=True)
df2['GP'] = 1.0 * (df2['total_operating_revenue'] - df2['total_operating_cost']) / df2['total_assets']
df2 = df2.drop(['total_assets', 'total_operating_revenue', 'total_operating_cost'], axis=1)
df2 = df2.sort_values(by='GP', ascending=False)
PR = df2['code'].tolist()
for i, v in enumerate(PR, 1):
set_PR[v] = int(i)
for stock in df1:
ps_value = set_PS.get(stock, -1)
pb_value = set_PB.get(stock, -1)
pr_value = set_PR.get(stock, -1)
if ps_value>0 and pb_value>0 and pr_value>0 :
c_n = ps_value + pb_value + pr_value
good_stocks[stock]=c_n
good_stocks = dict(sorted(good_stocks.items(), key=lambda x: x[1]))
stock_lists = list(good_stocks.keys())
stock_list = good_stocks
return stock_lists
class quantlib:
def fun_set_var(self, context, var_name, var_value):
if var_name not in dir(context):
setattr(context, var_name, var_value)
def fun_check_price(self, algo_name, stock_list, position_price, gap_trigger):
flag = False
msg = ""
if stock_list:
h = history(1, '1d', 'close', stock_list, df=False)
for stock in stock_list:
cur_price = h[stock][0]
if stock not in position_price:
position_price[stock] = cur_price
old_price = position_price[stock]
if old_price != 0:
delta_price = abs(cur_price - old_price)
if delta_price / old_price > gap_trigger:
msg = f"{algo_name} 需要调仓: {stock},现价: {cur_price} / 原价格: {old_price}\n"
flag = True
return flag, position_price, msg
return flag, position_price, msg
def fun_needRebalance(self, algo_name, moneyfund, stock_list, position_price, hold_periods, hold_cycle, gap_trigger):
msg = ""
msg += algo_name + "离下次调仓还剩 " + str(hold_periods) + " 天\n"
rebalance_flag = False
stocks_count = 0
for stock in stock_list:
if stock not in moneyfund:
stocks_count += 1
if stocks_count == 0:
msg += algo_name + "调仓,因为持股数为 0 \n"
rebalance_flag = True
elif hold_periods == 0:
msg += algo_name + "调仓,因为持股天数剩余为 0 \n"
rebalance_flag = True
if not rebalance_flag:
rebalance_flag, position_price, msg2 = self.fun_check_price(algo_name, stock_list, position_price, gap_trigger)
msg += msg2
if rebalance_flag:
hold_periods = hold_cycle
else:
hold_periods -= 1
return rebalance_flag, position_price, hold_periods, msg
# 更新持有股票的价格,每次调仓后跑一次
def fun_update_positions_price(self, ratio):
position_price = {}
if ratio:
h = history(1, '1m', 'close', ratio.keys(), df=False)
for stock in ratio.keys():
if ratio[stock] > 0:
position_price[stock] = round(h[stock][0], 3)
return position_price
def fun_assetAllocationSystem(self, stock_list, moneyfund, statsDate=None):
def __fun_getEquity_ratio(__stocklist, limit_up=1.0, limit_low=0.0, statsDate=None):
__ratio = {}
# Check if there is any stock in the list
if __stocklist:
# Calculate equal ratio for each stock
equal_ratio = 1.0 / len(__stocklist)
for stock in __stocklist:
__ratio[stock] = equal_ratio
return __ratio
equity_ratio = __fun_getEquity_ratio(stock_list, 1.0, 0.0, statsDate)
bonds_ratio = __fun_getEquity_ratio(moneyfund, 1.0, 0.0, statsDate)
return equity_ratio, bonds_ratio
def fun_calPosition(self, equity_ratio, bonds_ratio, algo_ratio, risk_ratio, moneyfund, portfolio_value, confidencelevel, statsDate=None):
'''
equity_ratio 资产配仓结果
bonds_ratio 债券配仓结果
algo_ratio 策略占市值的百分比
risk_ratio 每个标的承受的风险系数
'''
trade_ratio = equity_ratio # 例子,简单处理,略过
return trade_ratio
def fun_do_trade(self, context, trade_ratio, moneyfund, trade_style):
def __fun_tradeStock(context, stock, ratio, trade_style):
total_value = context.portfolio.portfolio_value
self.fun_trade(context, stock, ratio*total_value)
trade_list = trade_ratio.keys()
myholdstock = context.portfolio.positions.keys()
stock_list = list(set(trade_list).union(set(myholdstock)))
total_value = context.portfolio.portfolio_value
# 已有仓位
holdDict = {}
h = history(1, '1d', 'close', stock_list, df=False)
for stock in myholdstock:
tmpW = np.around(context.portfolio.positions[stock].total_amount * h[stock], decimals=2)
holdDict[stock] = float(tmpW)
# 对已有仓位做排序
tmpDict = {}
for stock in holdDict:
if stock in trade_ratio:
tmpDict[stock] = round((trade_ratio[stock] - holdDict[stock]), 2)
tradeOrder = sorted(tmpDict.items(), key=lambda d:d[1], reverse=False)
# 交易已有仓位的股票,从减仓的开始,腾空现金
_tmplist = []
for idx in tradeOrder:
stock = idx[0]
__fun_tradeStock(context, stock, trade_ratio[stock], trade_style)
_tmplist.append(stock)
# 交易新股票
for i in range(len(trade_list)):
stock = list(trade_list)[i]
if len(_tmplist) != 0 :
if stock not in _tmplist:
__fun_tradeStock(context, stock, trade_ratio[stock], trade_style)
else:
__fun_tradeStock(context, stock, trade_ratio[stock], trade_style)
def unpaused(self, stock_list, positions_list):
current_data = get_current_data()
tmpList = []
for stock in stock_list:
if not current_data[stock].paused or stock in positions_list:
tmpList.append(stock)
return tmpList
def remove_st(self, stock_list, statsDate):
current_data = get_current_data()
return [s for s in stock_list if not current_data[s].is_st]
# 剔除涨停板的股票(如果没有持有的话)
def remove_limit_up(self, stock_list, positions_list):
h = history(1, '1m', 'close', stock_list, df=False, skip_paused=False, fq='pre')
h2 = history(1, '1m', 'high_limit', stock_list, df=False, skip_paused=False, fq='pre')
tmpList = []
for stock in stock_list:
if h[stock][0] < h2[stock][0] or stock in positions_list:
tmpList.append(stock)
return tmpList
# 剔除上市时间较短的产品
def fun_delNewShare(self, context, equity, deltaday):
deltaDate = context.current_dt.date() - dt.timedelta(deltaday)
tmpList = []
for stock in equity:
if get_security_info(stock).start_date < deltaDate:
tmpList.append(stock)
return tmpList
def fun_trade(self, context, stock, value):
self.fun_setCommission(context, stock)
order_target_value(stock, value)
def fun_setCommission(self, context, stock):
# 将滑点设置为0
set_slippage(FixedSlippage(0))
# 根据不同的时间段设置手续费
dt=context.current_dt
if dt>datetime.datetime(2013,1, 1):
if stock in context.moneyfund:
set_order_cost(OrderCost(open_tax=0, close_tax=0, open_commission=0, close_commission=0, close_today_commission=0, min_commission=0), type='fund')
else:
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.0003, close_commission=0.0013, close_today_commission=0, min_commission=5), type='stock')
elif dt>datetime.datetime(2011,1, 1):
if stock in context.moneyfund:
set_order_cost(OrderCost(open_tax=0, close_tax=0, open_commission=0, close_commission=0, close_today_commission=0, min_commission=0), type='fund')
else:
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.001, close_commission=0.002, close_today_commission=0, min_commission=5), type='stock')
elif dt>datetime.datetime(2009,1, 1):
if stock in context.moneyfund:
set_order_cost(OrderCost(open_tax=0, close_tax=0, open_commission=0, close_commission=0, close_today_commission=0, min_commission=0), type='fund')
else:
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.002, close_commission=0.003, close_today_commission=0, min_commission=5), type='stock')
else:
if stock in context.moneyfund:
set_order_cost(OrderCost(open_tax=0, close_tax=0, open_commission=0, close_commission=0, close_today_commission=0, min_commission=0), type='fund')
else:
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.003, close_commission=0.004, close_today_commission=0, min_commission=5), type='stock')
Settings: Use the CSI 300 index as the return benchmark. Define the stock pool as '511880.XSHG', '511010.XSHG', '511220.XSHG'. Exclude stocks/futures that are limit up or limit down, suspended or resumed trading, and ST (Special Treatment) stocks. From here on, we are gradually transitioning from a single-factor model to a multi-factor model. The first thing to consider is how to select stocks. After analysis, when we do not know which factor has the greatest impact on the strategy, we adopt a fixed total score filtering approach: assign each stock a ranking number based on each factor from smallest to largest, and the sum of the rankings for different factors for a single stock becomes its total score. Since we need to measure the sensitivity of returns to this strategy and the core of the strategy is to select a combination of factors, based on the single-factor strategy analysis in section 2.5, we can see that the excellent stock-picking range for most single factors like PE and PB is generally between the top 30-50% (further verification required). This is because the lower the values, the better the earnings and the better the company's operations (excluding specific cases and industry differentiation). Therefore, our idea is to calculate the total score and then sort them from smallest to largest. Then, we compare the returns in different stock-picking ranges as well as various data to analyze the strategy's practicality.
As we begin to introduce various combinations of factors, there will be significant differences in the data. The reason is that in each single-factor screening, we have removed stocks with negative values for that factor (for example, in the PE factor, we have removed stocks with negative PE before combining with multi-factor sorting). So when we remove a factor, stocks with negative values for that factor return to the original stock pool. This is why there are discrepancies in the strategy, and it can also reflect the impact of stocks with negative values for different factors on strategy returns.
Specific algorithm as follows: We sort according to the factor index from smallest to largest, and then assign the corresponding ranking numbers. Then, after multi-factor sorting, we add the corresponding ranking numbers for that stock and sort based on the total sum. Next, we will compare the returns of different multi-factor strategies in different stock-picking ranges:
Code: import numpy as np import talib import pandas import scipy as sp import scipy.optimize import datetime as dt from scipy import linalg as sla from scipy import spatial from jqdata import * import smtplib from email.mime.text import MIMEText from email.header import Header import statsmodels.api as sm