Skip to content

Commit

Permalink
对回测系统做了大量修改,解决了自动平仓和turnover的bug,改进了check_order函数
Browse files Browse the repository at this point in the history
从前的回测系统只能适用于等权买卖股票的情况,v0.6之后可以按照投资组合的权重进行买卖,故相应地回测系统也要做大量的修改
  • Loading branch information
HaoningChen committed Dec 15, 2023
1 parent cbeac10 commit e35422b
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 77 deletions.
93 changes: 60 additions & 33 deletions scutquant/account.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from copy import deepcopy


class Account:
"""
传入:
Expand All @@ -22,11 +25,11 @@ class Account:

def __init__(self, init_cash: float, position: dict, available: dict, init_price: dict):
self.cash = init_cash # 可用资金
self.cash_available = init_cash
self.cash_available = deepcopy(init_cash)
self.position = position # keys应包括所有资产,如无头寸则值为0,以便按照keys更新持仓
self.available = available # 需要持有投资组合的底仓,否则按照T+1制度无法做空
self.price = init_price # 资产价格
self.value = init_cash # 市值,包括cash和持仓市值
self.value = deepcopy(init_cash) # 市值,包括cash和持仓市值
self.cost = 0.0 # 交易费用
self.val_hist = [] # 用于绘制市值曲线
self.buy_hist = [] # 买入记录
Expand All @@ -36,19 +39,42 @@ def __init__(self, init_cash: float, position: dict, available: dict, init_price
self.turnover = []
self.trade_value = 0.0

def generate_total_order(self, order: dict, freq: int) -> dict:
order_offset = self.auto_offset(freq)
if order_offset is not None:
for key in order_offset["buy"].keys():
if key not in order["buy"].keys():
order["buy"][key] = order_offset["buy"][key]
else:
order["buy"][key] += order_offset["buy"][key]

for key in order_offset["sell"].keys():
if key not in order["sell"].keys():
order["sell"][key] = order_offset["sell"][key]
else:
order["sell"][key] += order_offset["sell"][key]
return order

def check_order(self, order: dict, price: dict, cost_rate: float = 0.0015, min_cost: float = 5) -> \
tuple[dict, bool]: # 检查是否有足够的资金完成order, 如果不够则不买
# todo: 增加风险度判断(执行该order会不会超出最大风险度)
cash_inflow = 0.0
cash_outflow = 0.0
for code in order['sell'].keys():
if code in self.available.keys(): # 如果做空的品种有底仓, 则清空底仓
order["sell"][code] = self.available[code]
cash_inflow += price[code] * order['sell'][code]
for code in order['buy'].keys():
cash_outflow += price[code] * order['buy'][code]
order_copy = deepcopy(order)
for code in order_copy['sell'].keys(): # 对卖出指令的检查十分严格, 需要检查底仓中是否存在该资产, 以及头寸是多少
if code in price.keys() and code in self.available.keys():
order["sell"][code] = order["sell"][code] if order["sell"][code] <= self.available[code] else \
self.available[code]
cash_inflow += price[code] * order['sell'][code]
else:
order["sell"].pop(code)
for code in order_copy['buy'].keys():
if code in price.keys():
cash_outflow += price[code] * order['buy'][code]
else:
order["buy"].pop(code)
cost = max(min_cost, (cash_inflow + cash_outflow) * cost_rate)
cash_needed = cash_outflow - cash_inflow + cost
# print("cash_needed: ", cash_needed, "cash: ", self.cash)
if cash_needed > self.cash:
return order, False
else:
Expand Down Expand Up @@ -84,56 +110,57 @@ def buy(self, order_buy: dict, cost_rate: float = 0.0015, min_cost: float = 5):
self.position[code] = order_buy[code]
self.available[code] = order_buy[code]
buy_value += self.price[code] * order_buy[code]
self.trade_value += abs(buy_value)
self.trade_value += buy_value
cost = max(min_cost, buy_value * cost_rate)
self.cost += cost
self.cash -= (buy_value + cost) # 更新现金

def sell(self, order_sell: dict, cost_rate: float = 0.0005, min_cost: float = 5): # 卖出函数
# 做空时, 如果用底仓做空, 则清空底仓; 若融券做空, 则按照"short_volume"参数决定做空数量
# 因为无法融券做空, 所以如果底仓里面没有该资产则无法卖出
sell_value = 0.0
for code in order_sell.keys():
if code in self.position.keys():
if code in self.available.keys():
self.position[code] -= order_sell[code]
self.available[code] -= order_sell[code]
else:
self.position[code] = -order_sell[code]
self.available[code] = -order_sell[code]
sell_value += self.price[code] * order_sell[code]
self.trade_value += abs(sell_value)
sell_value += self.price[code] * order_sell[code]
self.trade_value += sell_value
cost = max(min_cost, sell_value * cost_rate) if sell_value != 0 else 0
self.cash += (sell_value - cost) # 更新现金

def update_all(self, order, price, cost_buy=0.0015, cost_sell=0.0005, min_cost=5, trade=True):
# 更新市场价格、交易记录、持仓和可交易数量、交易费用和现金,市值
# order的Key不一定要包括所有资产,但必须是position的子集
Account.update_price(self, price) # 首先更新市场价格
self.update_price(price) # 首先更新市场价格
self.trade_value = 0.0
value_before_trade = self.value
if order is not None:
if trade:
Account.update_trade_hist(self, order) # 然后更新交易记录
Account.sell(self, order['sell'], cost_buy, min_cost)
Account.buy(self, order['buy'], cost_sell, min_cost)
self.trade_value = abs(self.trade_value)
# 然后更新交易记录
self.update_trade_hist(order)
self.sell(order["sell"], cost_buy, min_cost)
self.buy(order["buy"], cost_sell, min_cost)
self.trade_value = self.trade_value
self.turnover.append(self.trade_value / value_before_trade)
Account.update_value(self)
self.update_value()

def auto_offset(self, freq: int, cost_buy: float = 0.0015, cost_sell: float = 0.0005, min_cost: float = 5): # 自动平仓
def auto_offset(self, freq: int): # 自动平仓
"""
example: 对某只股票的买入记录为[4, 1, 1, 2, 3], 假设买入后2个tick平仓, 则自动平仓应为[nan, nan, 4, 1, 1]
:param freq: 多少个tick后平仓, 例如收益率构建方式为close_-2 / close_-1 - 1, delta_t=1, 所以是1tick后平仓
:param cost_buy: 买入费率
:param cost_sell: 卖出费率
:param min_cost: 最小交易费用
:param freq: 多少个tick后平仓, 例如收益率构建方式为close_-2 / close_-1 - 1, delta_t=1, 所以是1 tick后平仓
:return:
"""
freq += 1
if len(self.buy_hist) >= freq:
offset_buy = self.sell_hist[-freq]
# offset_buy = self.sell_hist[-freq]
offset_sell = self.buy_hist[-freq]
Account.sell(self, offset_sell, cost_sell, min_cost) # 卖出平仓
Account.buy(self, offset_buy, cost_buy, min_cost) # 买入平仓
offset_order = {
"buy": {}, # 空头似乎不需要补仓
"sell": offset_sell
}
else:
offset_order = None
return offset_order

def risk_control(self, risk_degree: float, cost_rate: float = 0.0005, min_cost: float = 5):
# 控制风险, 当风险度超过计划风险度时, 按比例减少持仓
Expand All @@ -144,8 +171,8 @@ def risk_control(self, risk_degree: float, cost_rate: float = 0.0005, min_cost:
self.risk_curve.append(self.risk)
if self.risk > risk_degree:
b = 1 - risk_degree / self.risk
sell_order = self.position.copy()
sell_order = deepcopy(self.position)
for code in sell_order.keys():
sell_order[code] *= b
sell_order[code] = int(sell_order[code] / 100 + 0.5) * 100 # 以手为单位减持
Account.sell(self, sell_order, cost_rate, min_cost)
self.sell(sell_order, cost_rate, min_cost)
23 changes: 6 additions & 17 deletions scutquant/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,13 @@ def __init__(self, generator: dict, stra, acc: dict, trade_params: dict):
acc["position"] = {}
if "available" not in keys:
acc["available"] = {}
if "ben_position" not in keys:
acc["ben_position"] = {}

self.mode = generator['mode']

self.init_cash: float = acc['cash']
self.position: dict = acc['position']
self.value_hold: float = 0.0
self.available: dict = acc['available']
self.ben_position: dict = acc['ben_position']
self.ben_cash: float = acc['cash']

self.price = None
Expand Down Expand Up @@ -58,24 +55,17 @@ def init_account(self, data: pd.DataFrame):
position_zip, available_zip = zip(code, zero_list), zip(code, zero_list)
self.position = dict(position_zip)
self.available = dict(available_zip)
"""
if self.ben_position is None: # 如果股票池固定, 那这段代码才是有意义的
cash_invest = self.init_cash / len(code) # 将可用资金均匀地投资每项资产(虽然这样做是不对的,应该分配不同权重), 得到指数
self.ben_position = dict() # 不用初始化available,因为不交易
for code in self.price.keys():
self.ben_position[code] = int(cash_invest / (self.price[code] * 100) + 0.5) * 100 # 四舍五入取整, 以百为单位
self.ben_cash -= self.ben_position[code] * self.price[code]
"""

def create_account(self):
self.user_account = account.Account(self.init_cash, self.position, self.available, self.price)
self.benchmark = account.Account(self.ben_cash, self.ben_position, {}, self.price.copy())
self.benchmark = account.Account(self.ben_cash, {}, {}, self.price.copy())

def get_cash_available(self):
self.value_hold = 0.0
for code in self.user_account.price.keys(): # 更新持仓市值, 如果持有的资产在价格里面, 更新资产价值
if code in self.user_account.position.keys():
self.value_hold += self.user_account.position[code] * self.price[code]
self.value_hold += self.user_account.position[code] * self.user_account.price[code]
# value是账户总价值, 乘risk_deg后得到所有可交易资金, 减去value_hold就是剩余可交易资金
return self.user_account.value * self.s.risk_degree - self.value_hold

def execute(self, data: pd.DataFrame, verbose: int = 0):
Expand Down Expand Up @@ -103,15 +93,14 @@ def check_names(index=data.index, predict="predict", price="price"):
idx = data["R"].groupby(data.index.names[0]).mean() # 大盘收益率
self.time.append(t)
data_select = data[data.index.get_level_values(0) == t]
signal = generate(data=data_select, strategy=self.s, cash_available=Executor.get_cash_available(self))
signal = generate(data=data_select, strategy=self.s, cash_available=self.get_cash_available())
order, current_price = signal["order"], signal["current_price"]

if self.s.auto_offset:
self.user_account.auto_offset(freq=self.s.offset_freq, cost_buy=self.cost_buy,
cost_sell=self.cost_sell, min_cost=self.min_cost)
order = self.user_account.generate_total_order(order=order, freq=self.s.offset_freq)
order, trade = self.user_account.check_order(order, current_price)

if verbose == 1:
if verbose == 1 and trade:
print(t, '\n', "buy:", '\n', order["buy"], '\n', "sell:", order["sell"], '\n')

self.user_account.update_all(order=order, price=current_price, cost_buy=self.cost_buy,
Expand Down
47 changes: 31 additions & 16 deletions scutquant/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ def report_all(user_account, benchmark, show_raw_value: bool = False, excess_ret
days += 1
days /= len(acc_ret)

acc_mdd = calc_drawdown(pd.Series(acc_ret))
ben_mdd = calc_drawdown(pd.Series(ben_ret))
acc_dd = calc_drawdown(pd.Series(acc_ret))
ben_dd = calc_drawdown(pd.Series(ben_ret))

ret = pd.Series(acc_ret) # 累计收益率
ben = pd.Series(ben_ret) # benchmark的累计收益率
Expand All @@ -186,8 +186,8 @@ def report_all(user_account, benchmark, show_raw_value: bool = False, excess_ret
print('Cumulative Rate of Return:', acc_ret[-1])
print('Cumulative Rate of Return(Benchmark):', ben_ret[-1])
print('Cumulative Excess Rate of Return:', excess_ret[-1], '\n')
print('Max Drawdown:', acc_mdd.min())
print('Max Drawdown(Benchmark):', ben_mdd.min())
print('Max Drawdown:', acc_dd.min())
print('Max Drawdown(Benchmark):', ben_dd.min())
print('Max Drawdown(Excess Return):', calc_drawdown(pd.Series(excess_ret) + 1).min(), '\n')
print('Sharpe Ratio:', sharpe)
print('Sortino Ratio:', sortino)
Expand All @@ -198,19 +198,34 @@ def report_all(user_account, benchmark, show_raw_value: bool = False, excess_ret
print('Profitable Days(%):', days)

if show_raw_value:
acc_val = pd.DataFrame(acc_val, columns=["acc_val"], index=time)
ben_val = pd.DataFrame(ben_val, columns=["acc_val"], index=time)
plot([acc_val, ben_val], label=['cum_return', 'benchmark'], title='Return', ylabel='value',
figsize=figsize)
acc_val = pd.Series(acc_val, name="acc_val", index=time)
ben_val = pd.Series(ben_val, name="ben_val", index=time)
plt.figure(figsize=(10, 6))
plt.plot(acc_val, label="return", color="red")
plt.plot(ben_val, label="benchmark", color="blue")
plt.plot(acc_val - ben_val, label="excess_return", color="orange")
plt.legend()
plt.show()
else:
acc_ret = pd.DataFrame(acc_ret, columns=["acc_ret"], index=time)
ben_ret = pd.DataFrame(ben_ret, columns=["acc_ret"], index=time)
plot([acc_ret, ben_ret], label=['cum_return_rate', 'benchmark'], title='Rate of Return',
ylabel='value', figsize=figsize)
if excess_return:
excess_ret = pd.DataFrame(excess_ret, columns=["excess_ret"], index=time)
plot([excess_ret], label=['excess_return'], title='Excess Rate of Return', ylabel='value',
figsize=figsize)
acc_ret = pd.Series(acc_ret, name="acc_ret", index=time)
ben_ret = pd.Series(ben_ret, name="ben_ret", index=time)
excess_ret = pd.Series(excess_ret, name="excess_ret", index=time)

plt.figure(figsize=(10, 6))
plt.plot(acc_ret, label="return", color="red")
plt.plot(ben_ret, label="benchmark", color="blue")
plt.plot(excess_ret, label="excess_return", color="orange")
plt.legend()
plt.title("Returns")
plt.show()

plt.clf()
plt.figure(figsize=(10, 6))
plt.plot(acc_dd, label="drawdown")
plt.plot(ben_dd, label="excess_return_drawdown")
plt.legend()
plt.title("Drawdown")
plt.show()

if risk:
risk = pd.DataFrame({'risk': user_account.risk_curve}, index=time)
Expand Down
6 changes: 3 additions & 3 deletions scutquant/signal_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ def get_trade_volume(signal: pd.Series, price: pd.Series, volume: pd.Series, thr
:param unit:
:return:
"""
trade_volume = signal / price
trade_volume = abs(signal) / price # 现在是以股作为单位
if unit == "lot":
trade_volume /= 100
max_volume = volume * threshold
trade_volume /= 100 # 除以100使其变成以手为单位
max_volume = volume * threshold # 这也是以手为单位
trade_volume.where(trade_volume <= max_volume, max_volume)
return (trade_volume + 0.5).astype(int) * 100 # 四舍五入取整, 最后以股作为单位

Expand Down
14 changes: 6 additions & 8 deletions scutquant/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
......
}
"""
import pandas as pd


def get_price(data, price: str = "price") -> dict:
def get_price(data: pd.DataFrame, price: str = "price") -> dict:
current_price = data.droplevel(0)[price].to_dict()
return current_price


def get_vol(data, volume: str = "volume") -> dict:
def get_vol(data: pd.DataFrame, volume: str = "volume") -> dict:
current_volume = data.droplevel(0)[volume].to_dict()
return current_volume

Expand Down Expand Up @@ -41,8 +42,8 @@ def to_signal(self, **kwargs):
class TopKStrategy(BaseStrategy):
"""
受分组累计收益率启发(参考report模块的group_return_ana): 做多预测收益率最高的n个资产, 做空预测收益率最低的n个资产,
并在未来平仓. 此时得到的收益率是最高的.
同样地, 由于中国市场难以做空, 我们仍然可以设buy_only=True. 这样得到的收益就是Group1的收益.
并在未来平仓.
同样地, 由于中国市场难以做空, 我们可以设buy_only=True.
这里的k是个百分数, 意为做多资产池中排前k%的资产, 做空排后k%的资产
"""

Expand All @@ -58,8 +59,6 @@ def __init__(self, kwargs=None):
kwargs["offset_freq"] = 1
if "long_only" not in kwargs.keys():
kwargs["long_only"] = False
if "short_volume" not in kwargs.keys():
kwargs["short_volume"] = 0 # 为0时,只能用底仓做空; >0时允许融券做空
if "unit" not in kwargs.keys():
kwargs["unit"] = "lot"
if "risk_degree" not in kwargs.keys():
Expand All @@ -71,12 +70,11 @@ def __init__(self, kwargs=None):
self.auto_offset = kwargs["auto_offset"]
self.offset_freq = kwargs["offset_freq"]
self.long_only = kwargs["long_only"]
self.num = kwargs["short_volume"]
self.unit = kwargs["unit"]
self.risk_degree = kwargs["risk_degree"]
self.max_volume = kwargs["max_volume"]

def to_signal(self, data, pred="predict", index_level="code"):
def to_signal(self, data: pd.DataFrame, pred: str = "predict", index_level: str = "code"):
n_k = int(len(data) * self.k + 0.5)
price = get_price(data, price="price")
data_ = data.copy().sort_values("predict", ascending=False)
Expand Down

0 comments on commit e35422b

Please sign in to comment.