w-trade-backtest 1.0.3
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.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/README.md +167 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/renderOrdersTable.js +217 -0
- package/dist/renderOrdersTimeline.js +159 -0
- package/dist/renderProfitPlot.js +198 -0
- package/dist/renderSummary.js +194 -0
- package/dist/tmp.html +232 -0
- package/dist/w-trade-backtest.umd.js +32 -0
- package/dist/w-trade-backtest.umd.js.map +1 -0
- package/docs/calcOrders.mjs.html +278 -0
- package/docs/calcOrdersRatio.mjs.html +146 -0
- package/docs/calcOrdersSummary.mjs.html +506 -0
- package/docs/calcOrdersSummarySimple.mjs.html +207 -0
- package/docs/calcSummary.mjs.html +201 -0
- package/docs/closeAndSummaryOrders.mjs.html +265 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/genReport.mjs.html +196 -0
- package/docs/genReportCore.mjs.html +128 -0
- package/docs/global.html +4231 -0
- package/docs/index.html +84 -0
- package/docs/runStrategies.mjs.html +244 -0
- package/docs/runStrategy.mjs.html +455 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/g.mjs +146 -0
- package/package.json +30 -0
- package/script.txt +19 -0
- package/src/WTradeBacktest.mjs +27 -0
- package/src/calcOrders.mjs +206 -0
- package/src/calcOrdersRatio.mjs +74 -0
- package/src/calcOrdersSummary.mjs +434 -0
- package/src/calcOrdersSummarySimple.mjs +135 -0
- package/src/calcSummary.mjs +129 -0
- package/src/closeAndSummaryOrders.mjs +193 -0
- package/src/genReport.mjs +124 -0
- package/src/genReportCore.mjs +56 -0
- package/src/ott.mjs +28 -0
- package/src/readJson.mjs +26 -0
- package/src/renderOrdersTable.js +217 -0
- package/src/renderOrdersTimeline.js +159 -0
- package/src/renderProfitPlot.js +198 -0
- package/src/renderSummary.js +194 -0
- package/src/runStrategies.mjs +172 -0
- package/src/runStrategy.mjs +383 -0
- package/src/tmp.html +232 -0
- package/src/writeJson.mjs +31 -0
- package/src/writeTxt.mjs +19 -0
- package/test/unit-WTradeBacktest.test.mjs +541 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +5 -0
- package/toolg/gDistRollup.mjs +51 -0
- package/toolg/gDocsExams.mjs +48 -0
- package/toolg/modifyReadme.mjs +4 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import each from 'lodash-es/each.js'
|
|
2
|
+
import filter from 'lodash-es/filter.js'
|
|
3
|
+
import get from 'lodash-es/get.js'
|
|
4
|
+
import isNumber from 'lodash-es/isNumber.js'
|
|
5
|
+
import map from 'lodash-es/map.js'
|
|
6
|
+
import size from 'lodash-es/size.js'
|
|
7
|
+
import sortBy from 'lodash-es/sortBy.js'
|
|
8
|
+
import dig from 'wsemi/src/dig.mjs'
|
|
9
|
+
import haskey from 'wsemi/src/haskey.mjs'
|
|
10
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
11
|
+
import ispnum from 'wsemi/src/ispnum.mjs'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 統計全部訂單之回測摘要
|
|
16
|
+
*
|
|
17
|
+
* 統計交易次數、勝率、最大回撤、最大持倉、夏普值、最終權益與各盈虧比例等欄位
|
|
18
|
+
* 訂單需先經calcOrders結算(含uEquity)與calcOrdersRatio計算(含dayHold與rProfitOrLossDay,夏普值計算所需)
|
|
19
|
+
* uIni非正數或timeOhlcStart、timeOhlcEnd非有效字串時throw
|
|
20
|
+
*
|
|
21
|
+
* Unit Test: {@link https://github.com/yuda-lyu/w-trade-backtest/blob/master/test/unit-WTradeBacktest.test.mjs Github}
|
|
22
|
+
* @function
|
|
23
|
+
* @param {Function} ott 輸入時區時間函數,傳入時間字串回傳dayjs時間物件(可用src/ott.mjs或自行以dayjs包裝)
|
|
24
|
+
* @param {Number} uIni 輸入初始資金正數
|
|
25
|
+
* @param {Array} ordersAll 輸入已結算訂單陣列
|
|
26
|
+
* @param {String} timeOhlcStart 輸入回測起始秒時間字串
|
|
27
|
+
* @param {String} timeOhlcEnd 輸入回測結束秒時間字串
|
|
28
|
+
* @returns {Object} 回傳回測摘要物件
|
|
29
|
+
* @example
|
|
30
|
+
*
|
|
31
|
+
* let arrOhlc = [
|
|
32
|
+
* { time: '2020-01-01T00:00:00', Open: 100, High: 101, Low: 99, Close: 100 },
|
|
33
|
+
* { time: '2020-01-01T04:00:00', Open: 100, High: 106, Low: 100, Close: 105 },
|
|
34
|
+
* { time: '2020-01-01T08:00:00', Open: 105, High: 107, Low: 102, Close: 103 },
|
|
35
|
+
* { time: '2020-01-01T12:00:00', Open: 103, High: 104, Low: 94, Close: 95 },
|
|
36
|
+
* { time: '2020-01-01T16:00:00', Open: 95, High: 98, Low: 92, Close: 93 },
|
|
37
|
+
* { time: '2020-01-01T20:00:00', Open: 93, High: 99, Low: 95, Close: 97 },
|
|
38
|
+
* ]
|
|
39
|
+
*
|
|
40
|
+
* //ordersSubmit, 4張下單: 前3張分別於後續K棒觸發止盈或止損, 第4張未觸發(未平倉)
|
|
41
|
+
* let kpDef = { uTrade: 100, timeEnd: '', priceEnd: '', modeResult: '', uFee: 0.05 }
|
|
42
|
+
* let ordersSubmit = [
|
|
43
|
+
* { mode: 'long', timeStart: '2020-01-01T00:00:00', priceStart: 100, priceTakeProfit: 105, priceStopLoss: 97, ...kpDef },
|
|
44
|
+
* { mode: 'long', timeStart: '2020-01-01T04:00:00', priceStart: 105, priceTakeProfit: 110.25, priceStopLoss: 101.85, ...kpDef },
|
|
45
|
+
* { mode: 'short', timeStart: '2020-01-01T08:00:00', priceStart: 103, priceTakeProfit: 97.85, priceStopLoss: 106.09, ...kpDef },
|
|
46
|
+
* { mode: 'long', timeStart: '2020-01-01T16:00:00', priceStart: 93, priceTakeProfit: 105, priceStopLoss: 85, ...kpDef },
|
|
47
|
+
* ]
|
|
48
|
+
*
|
|
49
|
+
* let orders = await calcOrders(arrOhlc, ordersSubmit, { uIni: 1000 })
|
|
50
|
+
* orders = calcOrdersRatio(ott, orders)
|
|
51
|
+
* let summary = calcOrdersSummary(ott, 1000, orders, '2020-01-01T00:00:00', '2020-01-01T20:00:00')
|
|
52
|
+
* console.log(summary)
|
|
53
|
+
* // => {
|
|
54
|
+
* // btDays: 0,
|
|
55
|
+
* // btYears: '0.0',
|
|
56
|
+
* // numTrade: 4,
|
|
57
|
+
* // numTradeFin: 3,
|
|
58
|
+
* // numTradeUnsettled: 1,
|
|
59
|
+
* // uTradeAllMax: 200,
|
|
60
|
+
* // rTradeAllMax: '20.00%',
|
|
61
|
+
* // numTradeAllMax: 2,
|
|
62
|
+
* // uDrawdownMax: 3.1000000000000227,
|
|
63
|
+
* // rDrawdownMax: '0.31%',
|
|
64
|
+
* // rEquivalentDrawdownMax: '1.51%',
|
|
65
|
+
* // rSharpe: 7.675814289051032,
|
|
66
|
+
* // rWin: '66.67%',
|
|
67
|
+
* // uEquityFinal: 1006.6999999999999,
|
|
68
|
+
* // uCumuProfitOrLossFinal: 6.700000000000006,
|
|
69
|
+
* // rCumuProfitOrLossFinal: '0.67%',
|
|
70
|
+
* // rCumuProfitOrLossFinalNormYear: '0.00%',
|
|
71
|
+
* // rEquivalentCumuProfitOrLossFinal: '3.35%',
|
|
72
|
+
* // rEquivalentCumuProfitOrLossFinalNormYear: '0.00%'
|
|
73
|
+
* // }
|
|
74
|
+
*
|
|
75
|
+
*/
|
|
76
|
+
let calcOrdersSummary = (ott, uIni, ordersAll, timeOhlcStart, timeOhlcEnd) => {
|
|
77
|
+
|
|
78
|
+
if (!ispnum(uIni)) {
|
|
79
|
+
throw new Error(`uIni[${uIni}] is not a positive number`)
|
|
80
|
+
}
|
|
81
|
+
if (!isestr(timeOhlcStart)) {
|
|
82
|
+
throw new Error(`invalid timeOhlcStart[${timeOhlcStart}]`)
|
|
83
|
+
}
|
|
84
|
+
if (!isestr(timeOhlcEnd)) {
|
|
85
|
+
throw new Error(`invalid timeOhlcEnd[${timeOhlcEnd}]`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//sortBy, 已或未平倉訂單一定有timeStart欄位
|
|
89
|
+
ordersAll = sortBy(ordersAll, 'timeStart')
|
|
90
|
+
|
|
91
|
+
let ordersFin = []
|
|
92
|
+
let ordersUnsettled = []
|
|
93
|
+
each(ordersAll, (o) => {
|
|
94
|
+
if (isestr(o.modeResult)) {
|
|
95
|
+
ordersFin.push(o)
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
ordersUnsettled.push(o)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let numTrade = size(ordersAll) //總交易次數
|
|
103
|
+
let numTradeFin = size(ordersFin) //總已完成交易次數
|
|
104
|
+
let numTradeUnsettled = size(ordersUnsettled) //總未完成交易次數
|
|
105
|
+
let rWin = dig(size(filter(ordersAll, { modeResult: 'profit' })) / numTradeFin * 100, 2) + '%' //單位為%
|
|
106
|
+
let uDrawdownMax = 0 //最大回撤金額(USDT)
|
|
107
|
+
let rDrawdownMax = '' //最大回撤比例(%)
|
|
108
|
+
let rEquivalentDrawdownMax = '' //等效最大回撤比例(%)
|
|
109
|
+
let uEquityFinal = 0 //最終權益(USDT)
|
|
110
|
+
let uCumuProfitOrLossFinal = '' //最終盈虧(USDT)
|
|
111
|
+
let rCumuProfitOrLossFinal = '' //最終盈虧比例(%)
|
|
112
|
+
let rEquivalentCumuProfitOrLossFinal = '' //最終等效盈虧比例(%)
|
|
113
|
+
let uTradeAllMax = 0 //最大持倉金額(USDT)
|
|
114
|
+
let rTradeAllMax = '' //最大持倉比例(%)
|
|
115
|
+
let numTradeAllMax = 0 //最大持倉筆數(同時持有的部位數)
|
|
116
|
+
let rSharpe = 0 //夏普值
|
|
117
|
+
let uMax = uIni //最大盈虧(USDT)
|
|
118
|
+
let uMaxMax = uIni //最大回撤時之最大盈虧(USDT)
|
|
119
|
+
|
|
120
|
+
//kpTrade(持倉金額變化), kpCount(持倉筆數變化)
|
|
121
|
+
let kpTrade = {}
|
|
122
|
+
let kpCount = {}
|
|
123
|
+
each(ordersAll, (o) => {
|
|
124
|
+
|
|
125
|
+
//須已平倉單才能計算最大回撤
|
|
126
|
+
if (isestr(o.modeResult)) {
|
|
127
|
+
|
|
128
|
+
//uMax
|
|
129
|
+
uMax = Math.max(uMax, o.uEquity)
|
|
130
|
+
|
|
131
|
+
//uDrawdownMax, rDrawdownMax
|
|
132
|
+
let uDrawdown = uMax - o.uEquity
|
|
133
|
+
let rDrawdown = dig(uDrawdown / uMax * 100, 2) + '%' //單位為%
|
|
134
|
+
if (uDrawdown > uDrawdownMax) {
|
|
135
|
+
uDrawdownMax = uDrawdown
|
|
136
|
+
uMaxMax = uMax
|
|
137
|
+
rDrawdownMax = rDrawdown
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
//已或未平倉皆可計算最大持倉
|
|
143
|
+
if (true) {
|
|
144
|
+
|
|
145
|
+
//dir, 持倉方向
|
|
146
|
+
let dir = null
|
|
147
|
+
// dir = o.mode === 'long' ? 1 : -1 //long為正而short為負, 可抵銷
|
|
148
|
+
dir = 1 //long與short皆為1(皆會增加持倉)
|
|
149
|
+
// console.log('o.mode', o.mode, dir)
|
|
150
|
+
|
|
151
|
+
//下單就增加持倉
|
|
152
|
+
if (isestr(o.timeStart)) {
|
|
153
|
+
if (!haskey(kpTrade, o.timeStart)) {
|
|
154
|
+
kpTrade[o.timeStart] = 0
|
|
155
|
+
}
|
|
156
|
+
kpTrade[o.timeStart] += dir * o.uTrade
|
|
157
|
+
// console.log('o.timeStart', kpTrade[o.timeStart])
|
|
158
|
+
if (!haskey(kpCount, o.timeStart)) {
|
|
159
|
+
kpCount[o.timeStart] = 0
|
|
160
|
+
}
|
|
161
|
+
kpCount[o.timeStart] += 1 //開倉筆數+1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
//結單就減少持倉
|
|
165
|
+
if (isestr(o.timeEnd)) {
|
|
166
|
+
if (!haskey(kpTrade, o.timeEnd)) {
|
|
167
|
+
kpTrade[o.timeEnd] = 0
|
|
168
|
+
}
|
|
169
|
+
kpTrade[o.timeEnd] -= dir * o.uTrade
|
|
170
|
+
// console.log('o.timeEnd', kpTrade[o.timeEnd])
|
|
171
|
+
if (!haskey(kpCount, o.timeEnd)) {
|
|
172
|
+
kpCount[o.timeEnd] = 0
|
|
173
|
+
}
|
|
174
|
+
kpCount[o.timeEnd] -= 1 //平倉筆數-1
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
})
|
|
180
|
+
// console.log('kpTrade(未累計)', kpTrade)
|
|
181
|
+
|
|
182
|
+
//計算累計持倉
|
|
183
|
+
if (true) {
|
|
184
|
+
|
|
185
|
+
//trades, 轉陣列與排序
|
|
186
|
+
let trades = []
|
|
187
|
+
each(kpTrade, (uTradeAll, time) => {
|
|
188
|
+
if (!isNumber(uTradeAll)) {
|
|
189
|
+
console.log('time', time)
|
|
190
|
+
throw new Error(`invalid uTradeAll[${uTradeAll}]`)
|
|
191
|
+
}
|
|
192
|
+
trades.push({ time, uTradeAll })
|
|
193
|
+
})
|
|
194
|
+
trades = sortBy(trades, 'time')
|
|
195
|
+
// console.log('trades', trades)
|
|
196
|
+
|
|
197
|
+
//紀錄累計值
|
|
198
|
+
let uAccm = 0
|
|
199
|
+
let _trades = map(trades, (item) => {
|
|
200
|
+
uAccm += item.uTradeAll
|
|
201
|
+
return {
|
|
202
|
+
...item,
|
|
203
|
+
uAccm,
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
// console.log('_trades', _trades)
|
|
207
|
+
|
|
208
|
+
//_kpTrade
|
|
209
|
+
let _kpTrade = {}
|
|
210
|
+
each(_trades, (v) => {
|
|
211
|
+
_kpTrade[v.time] = v.uAccm
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
//update
|
|
215
|
+
kpTrade = _kpTrade
|
|
216
|
+
|
|
217
|
+
}
|
|
218
|
+
// console.log('kpTrade(累計)', kpTrade)
|
|
219
|
+
|
|
220
|
+
//計算累計持倉筆數(與kpTrade累計同款)
|
|
221
|
+
if (true) {
|
|
222
|
+
|
|
223
|
+
//counts, 轉陣列與排序
|
|
224
|
+
let counts = []
|
|
225
|
+
each(kpCount, (numTradeAll, time) => {
|
|
226
|
+
if (!isNumber(numTradeAll)) {
|
|
227
|
+
console.log('time', time)
|
|
228
|
+
throw new Error(`invalid numTradeAll[${numTradeAll}]`)
|
|
229
|
+
}
|
|
230
|
+
counts.push({ time, numTradeAll })
|
|
231
|
+
})
|
|
232
|
+
counts = sortBy(counts, 'time')
|
|
233
|
+
|
|
234
|
+
//紀錄累計值
|
|
235
|
+
let nAccm = 0
|
|
236
|
+
let _counts = map(counts, (item) => {
|
|
237
|
+
nAccm += item.numTradeAll
|
|
238
|
+
return {
|
|
239
|
+
...item,
|
|
240
|
+
nAccm,
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
//_kpCount
|
|
245
|
+
let _kpCount = {}
|
|
246
|
+
each(_counts, (v) => {
|
|
247
|
+
_kpCount[v.time] = v.nAccm
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
//update
|
|
251
|
+
kpCount = _kpCount
|
|
252
|
+
|
|
253
|
+
}
|
|
254
|
+
// console.log('kpCount(累計)', kpCount)
|
|
255
|
+
|
|
256
|
+
//計算當前持倉金額與最大當前持倉金額
|
|
257
|
+
ordersAll = map(ordersAll, (o) => {
|
|
258
|
+
|
|
259
|
+
//當前持倉金額(USDT), 以下單時間計算
|
|
260
|
+
let uTradeAll = get(kpTrade, o.timeStart, null)
|
|
261
|
+
if (!isNumber(uTradeAll)) {
|
|
262
|
+
throw new Error(`invalid uTradeAll`)
|
|
263
|
+
}
|
|
264
|
+
uTradeAll = Math.abs(uTradeAll) //做多或空皆會持有倉位, 負倉位(當前持倉金額)代表多空可抵銷, 故此處一律取絕對值顯示
|
|
265
|
+
o.uTradeAll = uTradeAll
|
|
266
|
+
|
|
267
|
+
//當前持倉金額佔比(%), 以下單時間計算
|
|
268
|
+
let rTradeAll = dig(o.uTradeAll / uIni * 100, 2) + '%' //單位為%
|
|
269
|
+
o.rTradeAll = rTradeAll
|
|
270
|
+
|
|
271
|
+
//uTradeAllMax, rTradeAllMax
|
|
272
|
+
if (uTradeAllMax < o.uTradeAll) {
|
|
273
|
+
uTradeAllMax = o.uTradeAll
|
|
274
|
+
rTradeAllMax = o.rTradeAll
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//當前持倉筆數(同時持有的部位數), 以下單時間計算
|
|
278
|
+
let numTradeAll = get(kpCount, o.timeStart, null)
|
|
279
|
+
if (!isNumber(numTradeAll)) {
|
|
280
|
+
throw new Error(`invalid numTradeAll`)
|
|
281
|
+
}
|
|
282
|
+
o.numTradeAll = numTradeAll
|
|
283
|
+
|
|
284
|
+
//numTradeAllMax
|
|
285
|
+
if (numTradeAllMax < o.numTradeAll) {
|
|
286
|
+
numTradeAllMax = o.numTradeAll
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return o
|
|
290
|
+
})
|
|
291
|
+
// console.log('ordersAll', ordersAll[size(ordersAll) - 1], size(ordersAll))
|
|
292
|
+
|
|
293
|
+
//uEquityFinal, uCumuProfitOrLossFinal, rCumuProfitOrLossFinal
|
|
294
|
+
if (true) {
|
|
295
|
+
let ind = size(ordersFin) - 1
|
|
296
|
+
uEquityFinal = get(ordersFin, `${ind}.uEquity`, '') //最終權益(USDT)
|
|
297
|
+
uCumuProfitOrLossFinal = get(ordersFin, `${ind}.uCumuProfitOrLoss`, '') //最終盈虧(USDT)
|
|
298
|
+
rCumuProfitOrLossFinal = get(ordersFin, `${ind}.rCumuProfitOrLoss`, '') //最終盈虧比例(%)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
//rEquivalentDrawdown, 使用最大回撤時之最大盈虧, 扣掉初始資金, 再基於全部交易後才能得知的最大持倉, 換算出等效最大回撤比例
|
|
302
|
+
if (true) {
|
|
303
|
+
let v
|
|
304
|
+
if ((uMaxMax - uIni + uTradeAllMax) > 0) {
|
|
305
|
+
v = uDrawdownMax / (uMaxMax - uIni + uTradeAllMax)
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
v = 0
|
|
309
|
+
}
|
|
310
|
+
rEquivalentDrawdownMax = dig(v * 100, 2) + '%' //單位為%
|
|
311
|
+
// console.log('uDrawdownMax', uDrawdownMax)
|
|
312
|
+
// console.log('uMaxMax', uMaxMax)
|
|
313
|
+
// console.log('uMaxMax - uIni', uMaxMax - uIni)
|
|
314
|
+
// console.log('uMaxMax - uIni + uTradeAllMax', uMaxMax - uIni + uTradeAllMax)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//rEquivalentCumuProfitOrLossFinal
|
|
318
|
+
if (uTradeAllMax > 0 && isNumber(uCumuProfitOrLossFinal)) {
|
|
319
|
+
rEquivalentCumuProfitOrLossFinal = uCumuProfitOrLossFinal / uTradeAllMax
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
rEquivalentCumuProfitOrLossFinal = 0
|
|
323
|
+
}
|
|
324
|
+
rEquivalentCumuProfitOrLossFinal = dig((rEquivalentCumuProfitOrLossFinal) * 100, 2) + '%' //單位為%
|
|
325
|
+
|
|
326
|
+
//rSharpe
|
|
327
|
+
if (true) {
|
|
328
|
+
|
|
329
|
+
let rAvgProfitOrLossDay = 0
|
|
330
|
+
let rStdProfitOrLossDay = 0
|
|
331
|
+
|
|
332
|
+
//rAvgProfitOrLossDay, 加權平均日盈虧比例(加權日均報酬), 把所有交易攤平成連續的日報酬
|
|
333
|
+
let rrW = 0
|
|
334
|
+
let rrD = 0
|
|
335
|
+
each(ordersFin, (o) => {
|
|
336
|
+
let rW = o.rProfitOrLossDay * o.dayHold
|
|
337
|
+
rrW += rW
|
|
338
|
+
let rD = o.dayHold
|
|
339
|
+
rrD += rD
|
|
340
|
+
})
|
|
341
|
+
if (rrD > 0) {
|
|
342
|
+
rAvgProfitOrLossDay = rrW / rrD
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//rStdProfitOrLossDay, 加權日盈虧比例標準差(加權日標準差)
|
|
346
|
+
let rtW = 0
|
|
347
|
+
each(ordersFin, (o) => {
|
|
348
|
+
let diff = o.rProfitOrLossDay - rAvgProfitOrLossDay
|
|
349
|
+
let rW = o.dayHold * (diff * diff)
|
|
350
|
+
rtW += rW
|
|
351
|
+
})
|
|
352
|
+
if (rrD > 1) {
|
|
353
|
+
rStdProfitOrLossDay = Math.sqrt(rtW / (rrD - 1))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
//rSharpe, 夏普值
|
|
357
|
+
if (rStdProfitOrLossDay > 0) {
|
|
358
|
+
rSharpe = (rAvgProfitOrLossDay / rStdProfitOrLossDay) * Math.sqrt(252)
|
|
359
|
+
}
|
|
360
|
+
if (rSharpe < -10) {
|
|
361
|
+
rSharpe = -10
|
|
362
|
+
}
|
|
363
|
+
if (rSharpe > 10) {
|
|
364
|
+
rSharpe = 10
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
}
|
|
368
|
+
// console.log('rSharpe', rSharpe)
|
|
369
|
+
|
|
370
|
+
let tStart = ott(timeOhlcStart)
|
|
371
|
+
let tEnd = ott(timeOhlcEnd)
|
|
372
|
+
|
|
373
|
+
//回測時長日
|
|
374
|
+
let btDays = tEnd.diff(tStart, 'day')
|
|
375
|
+
|
|
376
|
+
//回測時長年
|
|
377
|
+
let btYears = btDays / 365
|
|
378
|
+
|
|
379
|
+
//年化報酬率
|
|
380
|
+
let rCumuProfitOrLossFinalNormYear = ''
|
|
381
|
+
if (true) {
|
|
382
|
+
let v
|
|
383
|
+
if (uIni === 0 || btYears === 0) {
|
|
384
|
+
v = 0
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
v = (uEquityFinal - uIni) / uIni / btYears
|
|
388
|
+
}
|
|
389
|
+
rCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
|
|
390
|
+
}
|
|
391
|
+
// let rCumuProfitOrLossFinalNormYear = `${dig((uEquityFinal - uIni) / uIni / btYears * 100, 2)}%`
|
|
392
|
+
|
|
393
|
+
//等效年化報酬率
|
|
394
|
+
let rEquivalentCumuProfitOrLossFinalNormYear = ''
|
|
395
|
+
if (true) {
|
|
396
|
+
let v
|
|
397
|
+
if (uTradeAllMax === 0 || btYears === 0) {
|
|
398
|
+
v = 0
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
v = (uEquityFinal - uIni) / uTradeAllMax / btYears
|
|
402
|
+
}
|
|
403
|
+
rEquivalentCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
|
|
404
|
+
}
|
|
405
|
+
// let rEquivalentCumuProfitOrLossFinalNormYear = `${dig((uEquityFinal - uIni) / uTradeAllMax / btYears * 100, 2)}%`
|
|
406
|
+
|
|
407
|
+
//summary
|
|
408
|
+
let summary = {
|
|
409
|
+
btDays,
|
|
410
|
+
btYears: dig(btYears, 1),
|
|
411
|
+
numTrade,
|
|
412
|
+
numTradeFin,
|
|
413
|
+
numTradeUnsettled,
|
|
414
|
+
uTradeAllMax,
|
|
415
|
+
rTradeAllMax,
|
|
416
|
+
numTradeAllMax,
|
|
417
|
+
uDrawdownMax,
|
|
418
|
+
rDrawdownMax,
|
|
419
|
+
rEquivalentDrawdownMax,
|
|
420
|
+
rSharpe,
|
|
421
|
+
rWin,
|
|
422
|
+
uEquityFinal,
|
|
423
|
+
uCumuProfitOrLossFinal,
|
|
424
|
+
rCumuProfitOrLossFinal,
|
|
425
|
+
rCumuProfitOrLossFinalNormYear,
|
|
426
|
+
rEquivalentCumuProfitOrLossFinal,
|
|
427
|
+
rEquivalentCumuProfitOrLossFinalNormYear,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return summary
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
export default calcOrdersSummary
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//精簡版 calcOrdersSummary — 只算「選股熱路徑」需要的欄位, 純迴圈無 lodash、不需先跑 calcOrdersRatio。
|
|
2
|
+
//
|
|
3
|
+
//用途: walk-forward 選股每組合每日各算一次摘要(數百萬次), 原 calcOrdersRatio+calcOrdersSummary 走 lodash 多趟、配大量物件, 太慢。
|
|
4
|
+
// 本版直接從 orders 一趟算出排序所需的 {numTrade, rWin, 等效年化}(+ 幾個便宜欄), 不算 drawdown/Sharpe/累計曲線。
|
|
5
|
+
//
|
|
6
|
+
//輸入 ordersAll: 已含 timeStart/timeEnd/uTrade/uProfitOrLoss/uEquity/modeResult 的訂單(由 calcOrders/calcOrdersFast/ordersFromBars 設定)。
|
|
7
|
+
// 與原 calcOrdersSummary 的對應欄位 bit-exact:
|
|
8
|
+
// numTrade / numTradeFin / rWin / uTradeAllMax / rTradeAllMax / uEquityFinal / btDays / btYears /
|
|
9
|
+
// rEquivalentCumuProfitOrLossFinalNormYear
|
|
10
|
+
// (演算法與原版一致: 最大同時持倉 = 各單 [timeStart,timeEnd] 佔 uTrade 的最大同時總和, 以「各訂單 timeStart 時刻累計持倉」取 max;
|
|
11
|
+
// 等效年化 = (uEquityFinal - uIni) / uTradeAllMax / 年數。)
|
|
12
|
+
import dig from 'wsemi/src/dig.mjs'
|
|
13
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 統計全部訂單之精簡版回測摘要
|
|
17
|
+
*
|
|
18
|
+
* 為calcOrdersSummary之精簡版,只算選股熱路徑所需欄位,純迴圈實作且不需先跑calcOrdersRatio
|
|
19
|
+
* 對應欄位與完整版bit-exact: numTrade、numTradeFin、rWin、uTradeAllMax、rTradeAllMax、uEquityFinal、btDays、btYears、rEquivalentCumuProfitOrLossFinalNormYear
|
|
20
|
+
* 不算drawdown、夏普值與累計曲線
|
|
21
|
+
*
|
|
22
|
+
* Unit Test: {@link https://github.com/yuda-lyu/w-trade-backtest/blob/master/test/unit-WTradeBacktest.test.mjs Github}
|
|
23
|
+
* @function
|
|
24
|
+
* @param {Function} ott 輸入時區時間函數,傳入時間字串回傳dayjs時間物件(可用src/ott.mjs或自行以dayjs包裝)
|
|
25
|
+
* @param {Number} uIni 輸入初始資金正數
|
|
26
|
+
* @param {Array} ordersAll 輸入已結算訂單陣列(經calcOrders設定timeStart、timeEnd、uTrade、uEquity、modeResult)
|
|
27
|
+
* @param {String} timeOhlcStart 輸入回測起始秒時間字串
|
|
28
|
+
* @param {String} timeOhlcEnd 輸入回測結束秒時間字串
|
|
29
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
30
|
+
* @param {Boolean} [opt.skipSort=false] 輸入呼叫端保證ordersAll已依timeStart升序時可跳過排序布林值,預設false
|
|
31
|
+
* @returns {Object} 回傳精簡版回測摘要物件
|
|
32
|
+
* @example
|
|
33
|
+
*
|
|
34
|
+
* let arrOhlc = [
|
|
35
|
+
* { time: '2020-01-01T00:00:00', Open: 100, High: 101, Low: 99, Close: 100 },
|
|
36
|
+
* { time: '2020-01-01T04:00:00', Open: 100, High: 106, Low: 100, Close: 105 },
|
|
37
|
+
* { time: '2020-01-01T08:00:00', Open: 105, High: 107, Low: 102, Close: 103 },
|
|
38
|
+
* { time: '2020-01-01T12:00:00', Open: 103, High: 104, Low: 94, Close: 95 },
|
|
39
|
+
* { time: '2020-01-01T16:00:00', Open: 95, High: 98, Low: 92, Close: 93 },
|
|
40
|
+
* { time: '2020-01-01T20:00:00', Open: 93, High: 99, Low: 95, Close: 97 },
|
|
41
|
+
* ]
|
|
42
|
+
*
|
|
43
|
+
* //ordersSubmit, 4張下單: 前3張分別於後續K棒觸發止盈或止損, 第4張未觸發(未平倉)
|
|
44
|
+
* let kpDef = { uTrade: 100, timeEnd: '', priceEnd: '', modeResult: '', uFee: 0.05 }
|
|
45
|
+
* let ordersSubmit = [
|
|
46
|
+
* { mode: 'long', timeStart: '2020-01-01T00:00:00', priceStart: 100, priceTakeProfit: 105, priceStopLoss: 97, ...kpDef },
|
|
47
|
+
* { mode: 'long', timeStart: '2020-01-01T04:00:00', priceStart: 105, priceTakeProfit: 110.25, priceStopLoss: 101.85, ...kpDef },
|
|
48
|
+
* { mode: 'short', timeStart: '2020-01-01T08:00:00', priceStart: 103, priceTakeProfit: 97.85, priceStopLoss: 106.09, ...kpDef },
|
|
49
|
+
* { mode: 'long', timeStart: '2020-01-01T16:00:00', priceStart: 93, priceTakeProfit: 105, priceStopLoss: 85, ...kpDef },
|
|
50
|
+
* ]
|
|
51
|
+
*
|
|
52
|
+
* let orders = await calcOrders(arrOhlc, ordersSubmit, { uIni: 1000 })
|
|
53
|
+
* let summary = calcOrdersSummarySimple(ott, 1000, orders, '2020-01-01T00:00:00', '2020-01-01T20:00:00')
|
|
54
|
+
* console.log(summary)
|
|
55
|
+
* // => {
|
|
56
|
+
* // numTrade: 4,
|
|
57
|
+
* // numTradeFin: 3,
|
|
58
|
+
* // rWin: '66.67%',
|
|
59
|
+
* // uTradeAllMax: 200,
|
|
60
|
+
* // rTradeAllMax: '20.00%',
|
|
61
|
+
* // uEquityFinal: 1006.6999999999999,
|
|
62
|
+
* // btDays: 0,
|
|
63
|
+
* // btYears: '0.0',
|
|
64
|
+
* // rEquivalentCumuProfitOrLossFinalNormYear: '0.00%',
|
|
65
|
+
* // timeOhlcStart: '2020-01-01T00:00:00',
|
|
66
|
+
* // timeOhlcEnd: '2020-01-01T20:00:00'
|
|
67
|
+
* // }
|
|
68
|
+
*
|
|
69
|
+
*/
|
|
70
|
+
let calcOrdersSummarySimple = (ott, uIni, ordersAll, timeOhlcStart, timeOhlcEnd, opt = {}) => {
|
|
71
|
+
|
|
72
|
+
//依 timeStart 排序(與原版一致; uEquity 是 calcOrdersFast 依此序的累計)。
|
|
73
|
+
// opt.skipSort: 呼叫端保證 ordersAll 已依 timeStart 升序時可跳過 slice+sort(熱路徑省數千筆排序); 預設 false 維持原行為。
|
|
74
|
+
if (!(opt && opt.skipSort)) {
|
|
75
|
+
ordersAll = ordersAll.slice().sort((a, b) => a.timeStart < b.timeStart ? -1 : (a.timeStart > b.timeStart ? 1 : 0))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let numTrade = ordersAll.length
|
|
79
|
+
let numTradeFin = 0
|
|
80
|
+
let nWin = 0
|
|
81
|
+
let uEquityFinal = '' //無已平倉時 '' (對齊原版 _.get 預設)
|
|
82
|
+
|
|
83
|
+
//持倉變化事件: 下單 +uTrade, 平倉 -uTrade (long/short 皆 +1, 都佔用倉位)
|
|
84
|
+
let delta = new Map()
|
|
85
|
+
for (let o of ordersAll) {
|
|
86
|
+
if (isestr(o.modeResult)) {
|
|
87
|
+
numTradeFin++
|
|
88
|
+
if (o.modeResult === 'profit') nWin++
|
|
89
|
+
uEquityFinal = o.uEquity //依 timeStart 序, 最後一筆已平倉者勝出 = 原版 ordersFin[last].uEquity
|
|
90
|
+
}
|
|
91
|
+
delta.set(o.timeStart, (delta.get(o.timeStart) || 0) + o.uTrade)
|
|
92
|
+
if (isestr(o.timeEnd)) delta.set(o.timeEnd, (delta.get(o.timeEnd) || 0) - o.uTrade)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//累計持倉(各時刻) → 各訂單 timeStart 時刻取 max abs = 最大同時持倉
|
|
96
|
+
let times = [...delta.keys()].sort()
|
|
97
|
+
let cum = 0
|
|
98
|
+
let cumAt = new Map()
|
|
99
|
+
for (let t of times) {
|
|
100
|
+
cum += delta.get(t); cumAt.set(t, cum)
|
|
101
|
+
}
|
|
102
|
+
let uTradeAllMax = 0
|
|
103
|
+
for (let o of ordersAll) {
|
|
104
|
+
let v = Math.abs(cumAt.get(o.timeStart))
|
|
105
|
+
if (v > uTradeAllMax) uTradeAllMax = v
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let rWin = dig(nWin / numTradeFin * 100, 2) + '%'
|
|
109
|
+
let rTradeAllMax = dig(uTradeAllMax / uIni * 100, 2) + '%'
|
|
110
|
+
|
|
111
|
+
let btDays = ott(timeOhlcEnd).diff(ott(timeOhlcStart), 'day')
|
|
112
|
+
let btYears = btDays / 365
|
|
113
|
+
|
|
114
|
+
let rEquivalentCumuProfitOrLossFinalNormYear
|
|
115
|
+
{
|
|
116
|
+
let v = (uTradeAllMax === 0 || btYears === 0) ? 0 : (uEquityFinal - uIni) / uTradeAllMax / btYears
|
|
117
|
+
rEquivalentCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
numTrade,
|
|
122
|
+
numTradeFin,
|
|
123
|
+
rWin,
|
|
124
|
+
uTradeAllMax,
|
|
125
|
+
rTradeAllMax,
|
|
126
|
+
uEquityFinal,
|
|
127
|
+
btDays,
|
|
128
|
+
btYears: dig(btYears, 1),
|
|
129
|
+
rEquivalentCumuProfitOrLossFinalNormYear,
|
|
130
|
+
timeOhlcStart, //回傳區間起訖(echo 傳入參數, 對齊完整版 calcSummary:53-54; deterministic, 不破壞 bit-exact)
|
|
131
|
+
timeOhlcEnd,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default calcOrdersSummarySimple
|