最近有许多典型的非典型的策略,想要回测历史表现。市面上的回测服务存在繁琐、限制较多的问题,不能满足需要。粗略想了下回测框架的实现并不复杂,用熟悉的编程语言,自己动手也许是成本最低的方式。

核心元素

以单一股票的策略为例,输入「股票代码、回测时间段、策略逻辑」,输出「盈利结果、历史交易动作」即可。多只股票组合亦同理。

代码实现

最简版

包含注释在内的最简版的核心代码不到 50 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
export class BackTesting {
public readonly options: Options

constructor(options: Options) {
this.options = options
}

/**
* 核心运行代码
* 1. 获取指定时间、聚合粒度的 K 线列表
* 2. 逐一将每条 K 线数据及账户持仓信息回调给策略函数
* 3. 将回调函数产生的结果(新触发的交易)执行,更新账户余额和持仓信息
* 4. K 线运行完毕后返回账户信息(余额、持仓、净值收益、历史交易记录)
*/
public async execute(period: KLinePeriod, klineCallback: KLineCallback) {
const accountInfo: AccountInfo = { balance: 0, position: 0, tradingItems: [], profit: 0 }
const kLines = await this.getKLines(period)
for (const curKLine of kLines) {
this.tradeForAccount(accountInfo, klineCallback({ kline: curKLine, account: accountInfo }))
}
accountInfo.profit = accountInfo.balance + accountInfo.position * kLines[kLines.length - 1].close
return accountInfo
}

public tradeForAccount(accountInfo: AccountInfo, tradingItems: TradingItem[]) {
tradingItems.forEach((tradingItem) => {
switch (tradingItem.action) {
case 'BUY':
accountInfo.position += tradingItem.quantity
accountInfo.balance -= tradingItem.quantity * tradingItem.price
break
case 'SELL':
accountInfo.position -= tradingItem.quantity
accountInfo.balance += tradingItem.quantity * tradingItem.price
break
}
})
accountInfo.tradingItems.push(...tradingItems)
}

public async getKLines(period: KLinePeriod): Promise<Classical_KLine[]> {
const kLines = await KLineTools.getStockKLines(this.options.stockCode, {
period: period,
beginTime: this.options.startTime,
endTime: this.options.endTime,
})
return kLines
}
}

均线策略框架

均线相关的交易策略非常常见。于是可以在核心函数基础上实现一个新的均线策略测试函数,使回调函数携带均线信息供策略开发者直接使用。

为了让每条回调数据都尽可能拥有均值信息,如 MA20 需要额外向前获取 20 条数据,于是可将 getKLines 方法微调,使其支持自定义的开始时间

1
2
3
4
5
6
7
8
public async getKLines(period: KLinePeriod, startTime?: string): Promise<Classical_KLine[]> {
const kLines = await KLineTools.getStockKLines(this.options.stockCode, {
period: period,
beginTime: startTime || this.options.startTime,
endTime: this.options.endTime,
})
return kLines
}
  • testMA 支持 OptionsMA 传递,如 20 日均线为 { period: KLinePeriod.DAY, length: 20 }
  • 回调方法额外携带 prevMAcurMA 信息,供策略判断现价与均价的关系,以及均线朝向
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public async testMA(optionsMA: OptionsMA, klineCallback: MACallback) {
const accountInfo: AccountInfo = { balance: 0, position: 0, tradingItems: [], profit: 0 }
const { startTime } = this.options

// 请求 K 线数据的开始时间需要适当前移
const realStartTime = ((): string => {
if (optionsMA.period.endsWith('minute') || optionsMA.period.endsWith('hour')) {
return startTime
}
const period = optionsMA.period.split('/')![1] as any
return moment(startTime)
.subtract(optionsMA.length * 2, period)
.startOf(period)
.format('YYYY-MM-DD')
})()

// 向前额外获取更多 K 线
const kLines = await this.getKLines(optionsMA.period, realStartTime)

// 进行回测的起始数据下标
const startIndex = ((): number => {
const keyTs = moment(startTime).unix()
return Math.max(
kLines.findIndex((item) => moment(item.time).unix() >= keyTs),
optionsMA.length
)
})()

// startIndex 若小于 optionsMA.length,说明数据长度不足,不予回测
if (startIndex < optionsMA.length) {
return accountInfo
}

// prevSum 为 startIndex 之前的 optionsMA.length 条 K 线收盘价之和
let prevSum = kLines.slice(startIndex - optionsMA.length, startIndex).reduce((result, cur) => result + cur.close, 0)

for (let i = startIndex; i < kLines.length; ++i) {
const curKLine = kLines[i]
const curSum = prevSum - kLines[i - optionsMA.length].close + curKLine.close
/**
* 回调方法返回 K 线,前一周期均值、当前周期均值、当前账户持仓信息
* 通过 kline.close 与 curMA 的对比,可知收盘价是否站在均线上方
* 通过 prevMA 与 curMA 的对比,可知当前均线的运行方向是否朝上
*/
const items = klineCallback({
kline: curKLine,
prevMA: prevSum / optionsMA.length,
curMA: curSum / optionsMA.length,
account: accountInfo,
})
this.tradeForAccount(accountInfo, items)
prevSum = curSum
}
accountInfo.profit = accountInfo.balance + accountInfo.position * kLines[kLines.length - 1].close
return accountInfo
}

回测示例

回测恒指期货 2022-01-01 ~ 2024-01-24 “收盘价站上 MA20 日线做多,跌破 MA20 日线平仓” 的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const backTesting = new BackTesting({ stockCode: 'HSImain', startTime: '2022-01-01', endTime: '2024-01-24' })
const accountInfo = await backTesting.testMA(
{ period: KLinePeriod.DAY, length: 20 },
({ kline, curMA, account }) => {
const tradingItems: TradingItem[] = []
if (kline.close > curMA && account.position <= 0) {
tradingItems.push({ date: kline.time, action: 'BUY', price: kline.close, quantity: 1 })
} else if (kline.close < curMA && account.position > 0) {
tradingItems.push({ date: kline.time, action: 'SELL', price: kline.close, quantity: account.position })
}
return tradingItems
}
)
console.info(accountInfo)
console.info(`Trade ${accountInfo.tradingItems.length} times`)

输出结果为:交易 52 次,亏损 2111 😀

1
2
3
4
5
6
7
8
9
10
11
12
13
{
balance: -2111,
position: 0,
tradingItems: [
{ date: '2022-01-07', action: 'BUY', price: 23467, quantity: 1 },
{ date: '2022-01-27', action: 'SELL', price: 23837, quantity: 1 },
……
{ date: '2023-12-27', action: 'BUY', price: 16657, quantity: 1 },
{ date: '2024-01-05', action: 'SELL', price: 16589, quantity: 1 }
],
profit: -2111
}
Trade 52 times

后续利用

  • 有了易编程可复用的框架后,可将经典及突发奇想的策略落实为可执行代码,输入全市场全周期数据逐一验证
  • 输出结果包含交易明细,可用于绘制策略资产净值曲线,计算最大回撤
  • 对典型结果及非典型结果,可进行相关性归纳

附录(数据结构定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
export interface Classical_KLine {
time: string

open: number
close: number
low: number
high: number

prev_close: number
close_p: number

volume: number
turnover: number
}

export enum KLinePeriod {
MIN_1 = '1/minute',
MIN_5 = '5/minute',
MIN_15 = '15/minute',
MIN_30 = '30/minute',
MIN_60 = '60/minute',
HOUR_1 = '1/hour',
HOUR_4 = '4/hour',
DAY = '1/day',
WEEK = '1/week',
MONTH = '1/month',
QUARTER = '1/quarter',
YEAR = '1/year',
}

export interface TradingItem {
date: string
action: 'BUY' | 'SELL'
price: number
quantity: number
}

export interface AccountInfo {
balance: number
position: number
tradingItems: TradingItem[]
profit: number
}

interface CallbackCoreData {
kline: Classical_KLine
account: AccountInfo
}

export interface MACallbackData extends CallbackCoreData {
kline: Classical_KLine
account: AccountInfo
prevMA: number
curMA: number
}

export type KLineCallback<T extends CallbackCoreData = CallbackCoreData> = (data: T) => TradingItem[]

export type MACallback = (data: MACallbackData) => TradingItem[]