w-trade-backtest 1.0.8 → 1.1.2

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.
@@ -0,0 +1,298 @@
1
+ import assert from 'assert'
2
+ import buildStrategyFastSession from '../src/buildStrategyFastSession.mjs'
3
+ import runStrategy from '../src/runStrategy.mjs'
4
+ import calcOrdersSummarySimple from '../src/calcOrdersSummarySimple.mjs'
5
+ import ott from '../src/ott.mjs'
6
+ import { t00, t04, t08, t12, t16, t20, buildArrOhlc, buildArrSig } from './unit-setup.mjs'
7
+
8
+
9
+ //規格來源: src/buildStrategyFastSession.mjs
10
+ // buildStrategyFastSession: 策略快速評估session, 觸發/結算/摘要語義與官方全鏈(runStrategy→calcOrders→calcOrdersSummary)等值
11
+ // 等值性以property test鎖定: 隨機K線/因子序列(含缺值)/conds(含'or'分組)/tp≠sl/long與short,
12
+ // 對應欄位須與官方summary嚴格相等(含dig捨入後之%字串), uEquityFinal等浮點欄位為bit-exact(===)
13
+
14
+
15
+ //mulberry32, 種子化PRNG(測試可重現, 不用Math.random)
16
+ let mulberry32 = (seed) => {
17
+ let a = seed >>> 0
18
+ return () => {
19
+ a = (a + 0x6D2B79F5) >>> 0
20
+ let t = a
21
+ t = Math.imul(t ^ (t >>> 15), t | 1)
22
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
23
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
24
+ }
25
+ }
26
+
27
+ //buildTimes, 自2020-01-01T00:00:00起每4小時一根
28
+ let buildTimes = (nBars) => {
29
+ let base = ott('2020-01-01T00:00:00')
30
+ let times = []
31
+ for (let i = 0; i < nBars; i++) {
32
+ times.push(base.add(i * 4, 'hour').format('YYYY-MM-DDTHH:mm:ss'))
33
+ }
34
+ return times
35
+ }
36
+
37
+ //genRandomCase, 隨機產生一組(K線, 因子序列含缺值, conds含or分組, tp/sl非對稱, mode)
38
+ let genRandomCase = (rng) => {
39
+
40
+ //arrOhlc, 隨機walk且價格恆正
41
+ let nBars = 30 + Math.floor(rng() * 31)
42
+ let times = buildTimes(nBars)
43
+ let arrOhlc = []
44
+ let c = 100
45
+ for (let i = 0; i < nBars; i++) {
46
+ let c2 = c * (1 + (rng() - 0.5) * 0.08)
47
+ let hi = Math.max(c, c2) * (1 + rng() * 0.03)
48
+ let lo = Math.min(c, c2) * (1 - rng() * 0.03)
49
+ arrOhlc.push({ time: times[i], Open: c, High: hi, Low: lo, Close: c2 })
50
+ c = c2
51
+ }
52
+
53
+ //serieses, 每時間點以0.85機率存在(缺值=該時間點不存在), param為[0,1]
54
+ let keys = ['sigA', 'sigB', 'sigC'].slice(0, 1 + Math.floor(rng() * 3))
55
+ let serieses = {}
56
+ for (let key of keys) {
57
+ let arr = []
58
+ for (let i = 0; i < nBars; i++) {
59
+ if (rng() < 0.85) {
60
+ arr.push({ time: times[i], param: rng() })
61
+ }
62
+ }
63
+ serieses[key] = arr
64
+ }
65
+
66
+ //conds, 1~3條, 允許同key多條件, and/or混用
67
+ let nConds = 1 + Math.floor(rng() * 3)
68
+ let conds = []
69
+ for (let i = 0; i < nConds; i++) {
70
+ conds.push({
71
+ key: keys[Math.floor(rng() * keys.length)],
72
+ sym: rng() < 0.5 ? '>' : '<',
73
+ th: 0.2 + rng() * 0.6,
74
+ opr: rng() < 0.5 ? 'and' : 'or',
75
+ })
76
+ }
77
+
78
+ //tp/sl非對稱, 含淨虧止盈參數(tp=0.0008時tp*uTrade=0.08 < 2*uFee=0.1)
79
+ let tps = [0.0008, 0.02, 0.05]
80
+ let sls = [0.01, 0.03, 0.06]
81
+ let tp = tps[Math.floor(rng() * tps.length)]
82
+ let sl = sls[Math.floor(rng() * sls.length)]
83
+
84
+ let mode = rng() < 0.5 ? 'long' : 'short'
85
+
86
+ return { arrOhlc, serieses, conds, tp, sl, mode }
87
+ }
88
+
89
+ //st, 熱路徑常用設定
90
+ let st = { uIni: 1000, uTrade: 100, rFee: 0.0005 }
91
+
92
+ //runOfficial, 官方全鏈: runStrategy(內含calcOrders+calcOrdersRatio+calcOrdersSummary)
93
+ let runOfficial = async ({ arrOhlc, serieses, conds, tp, sl, mode }) => {
94
+ let funGetSeries = async (key) => {
95
+ if (key === 'ohlc') {
96
+ return arrOhlc
97
+ }
98
+ if (serieses[key]) {
99
+ return serieses[key]
100
+ }
101
+ throw new Error(`invalid key[${key}]`)
102
+ }
103
+ let strategy = {
104
+ mode,
105
+ keyOhlc: 'ohlc',
106
+ conds,
107
+ settings: { ...st, rTakeProfit: tp, rStopLoss: sl },
108
+ }
109
+ return runStrategy(ott, strategy, funGetSeries)
110
+ }
111
+
112
+ //keysSummary, 兩路須嚴格相等之摘要欄位(calcOrdersSummarySimple全欄位)
113
+ let keysSummary = [
114
+ 'numTrade',
115
+ 'numTradeFin',
116
+ 'rWin',
117
+ 'uTradeAllMax',
118
+ 'rTradeAllMax',
119
+ 'uEquityFinal',
120
+ 'btDays',
121
+ 'btYears',
122
+ 'rEquivalentCumuProfitOrLossFinalNormYear',
123
+ 'timeOhlcStart',
124
+ 'timeOhlcEnd',
125
+ ]
126
+
127
+ //assertSummaryEqual, 逐欄嚴格相等(===, 浮點欄位即bit-exact)
128
+ let assertSummaryEqual = (smFast, smOfficial, tag) => {
129
+ for (let k of keysSummary) {
130
+ assert.strictEqual(smFast[k], smOfficial[k], `${tag} summary.${k}: fast=${smFast[k]} official=${smOfficial[k]}`)
131
+ }
132
+ }
133
+
134
+ //keysOrder, withOrders慢速路徑須與官方訂單嚴格相等之欄位
135
+ let keysOrder = [
136
+ 'mode', 'timeStart', 'priceStart', 'uTrade', 'rTakeProfit', 'priceTakeProfit',
137
+ 'rStopLoss', 'priceStopLoss', 'timeEnd', 'priceEnd', 'modeResult', 'rFee', 'uFee',
138
+ 'uProfitOrLoss', 'rProfitOrLoss', 'uCumuProfitOrLoss', 'rCumuProfitOrLoss', 'uEquity',
139
+ ]
140
+
141
+ //assertCase, 單一case之等值斷言(summary + withOrders訂單 + simple↔full固化)
142
+ let assertCase = async (cs, tag) => {
143
+
144
+ //official
145
+ let r = await runOfficial(cs)
146
+
147
+ //fast session
148
+ let session = buildStrategyFastSession(ott, {
149
+ arrOhlc: cs.arrOhlc,
150
+ mode: cs.mode,
151
+ serieses: cs.serieses,
152
+ })
153
+ let smFast = session.evaluate({ conds: cs.conds, tp: cs.tp, sl: cs.sl, settings: st })
154
+ assertSummaryEqual(smFast, r.summary, tag)
155
+
156
+ //withOrders慢速路徑, 訂單逐欄對齊官方calcOrders結果
157
+ let { orders, summary } = session.evaluate({ conds: cs.conds, tp: cs.tp, sl: cs.sl, settings: st }, { withOrders: true })
158
+ assertSummaryEqual(summary, r.summary, `${tag}(withOrders)`)
159
+ assert.strictEqual(orders.length, r.orders.length, `${tag} orders.length`)
160
+ for (let i = 0; i < orders.length; i++) {
161
+ for (let k of keysOrder) {
162
+ assert.strictEqual(orders[i][k], r.orders[i][k], `${tag} orders[${i}].${k}: fast=${orders[i][k]} official=${r.orders[i][k]}`)
163
+ }
164
+ }
165
+
166
+ //同場加映: calcOrdersSummarySimple對官方訂單之輸出須與完整版calcOrdersSummary對應欄位嚴格相等(固化bit-exact宣稱)
167
+ let smSimple = calcOrdersSummarySimple(ott, st.uIni, r.orders, r.summary.timeOhlcStart, r.summary.timeOhlcEnd)
168
+ assertSummaryEqual(smSimple, r.summary, `${tag}(simple-vs-full)`)
169
+
170
+ return { r, smFast }
171
+ }
172
+
173
+
174
+ describe('buildStrategyFastSession', function() {
175
+
176
+ it('property test: 隨機K線/因子(含缺值)/conds(含or分組)/tp≠sl/long與short, 與官方全鏈嚴格相等(60組)', async function() {
177
+ this.timeout(60000)
178
+ for (let i = 0; i < 60; i++) {
179
+ let rng = mulberry32(12345 + i * 7919)
180
+ let cs = genRandomCase(rng)
181
+ await assertCase(cs, `seed[${12345 + i * 7919}]`)
182
+ }
183
+ })
184
+
185
+ it('邊角1: 同棒TP/SL同觸先判止損', async function() {
186
+ //t04單棒High=120/Low=80, tp=0.05與sl=0.03同棒同觸 → 先判止損
187
+ let arrOhlc = [
188
+ { time: t00, Open: 100, High: 101, Low: 99, Close: 100 },
189
+ { time: t04, Open: 100, High: 120, Low: 80, Close: 100 },
190
+ { time: t08, Open: 100, High: 101, Low: 99, Close: 100 },
191
+ ]
192
+ let serieses = { sig: [{ time: t00, param: 1 }, { time: t04, param: 0 }, { time: t08, param: 0 }] }
193
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
194
+ let cs = { arrOhlc, serieses, conds, tp: 0.05, sl: 0.03, mode: 'long' }
195
+ let { r } = await assertCase(cs, '邊角1')
196
+ assert.strictEqual(r.orders[0].modeResult, 'loss') //先判止損故為虧
197
+ assert.strictEqual(r.orders[0].priceEnd, 97) //出場為精確止損線
198
+ })
199
+
200
+ it('邊角2+4: 未結尾單計入numTrade不計入PnL, rWin分母為numTradeFin', async function() {
201
+ //t00觸發可結(t04觸TP), t16觸發後無K棒觸線 → 未結
202
+ let serieses = { sig: buildArrSig().map((v) => (v.time === t16 ? { ...v, param: 1 } : v)) }
203
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
204
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.05, sl: 0.5, mode: 'long' }
205
+ let { smFast } = await assertCase(cs, '邊角2')
206
+ assert.strictEqual(smFast.numTrade, 3) //t00, t08, t16
207
+ assert.ok(smFast.numTradeFin < smFast.numTrade, `numTradeFin=${smFast.numTradeFin}應小於numTrade=${smFast.numTrade}`)
208
+ })
209
+
210
+ it('邊角3: tp×uTrade≤2×uFee之淨虧止盈單, modeResult為loss', async function() {
211
+ //tp=0.0008 → tp*uTrade=0.08 < 2*uFee=0.1, 觸TP仍淨虧
212
+ let serieses = { sig: buildArrSig() }
213
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
214
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.0008, sl: 0.03, mode: 'long' }
215
+ let { r } = await assertCase(cs, '邊角3')
216
+ let oTp = r.orders.find((o) => o.priceEnd === o.priceTakeProfit)
217
+ assert.ok(oTp, '須有觸TP之單')
218
+ assert.strictEqual(oTp.modeResult, 'loss') //以淨盈虧判定而非以觸TP判定
219
+ })
220
+
221
+ it('邊角5: 末棒進場必為未結', async function() {
222
+ let serieses = { sig: buildArrSig().map((v) => (v.time === t20 ? { ...v, param: 1 } : { ...v, param: 0 })) }
223
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
224
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.05, sl: 0.03, mode: 'long' }
225
+ let { smFast } = await assertCase(cs, '邊角5')
226
+ assert.strictEqual(smFast.numTrade, 1)
227
+ assert.strictEqual(smFast.numTradeFin, 0)
228
+ assert.strictEqual(smFast.uEquityFinal, '') //無已平倉單, 對齊原版預設''
229
+ })
230
+
231
+ it('邊角6: 因子全缺值之空進場集, numTrade=0', async function() {
232
+ let serieses = { sig: [] } //全缺值
233
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
234
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.05, sl: 0.03, mode: 'long' }
235
+ let { smFast } = await assertCase(cs, '邊角6')
236
+ assert.strictEqual(smFast.numTrade, 0)
237
+ })
238
+
239
+ it('邊角7a: or分組之缺值棒整棒跳過(不得因另一or條件為真而進場)', async function() {
240
+ //sigA於t08缺值但sigB於t08為1: runStrategy因t08不在共同時間交集不進場, session須同步跳過
241
+ let serieses = {
242
+ sigA: [{ time: t00, param: 1 }, { time: t04, param: 0 }, { time: t12, param: 0 }, { time: t16, param: 0 }, { time: t20, param: 0 }], //t08缺值
243
+ sigB: [{ time: t00, param: 0 }, { time: t04, param: 0 }, { time: t08, param: 1 }, { time: t12, param: 0 }, { time: t16, param: 0 }, { time: t20, param: 0 }],
244
+ }
245
+ let conds = [
246
+ { key: 'sigA', sym: '>', th: 0.5, opr: 'or' },
247
+ { key: 'sigB', sym: '>', th: 0.5, opr: 'or' },
248
+ ]
249
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.05, sl: 0.03, mode: 'long' }
250
+ let { smFast } = await assertCase(cs, '邊角7a')
251
+ assert.strictEqual(smFast.numTrade, 1) //僅t00(sigA=1), t08因sigA缺值整棒跳過
252
+ })
253
+
254
+ it('邊角7b: 同key多條件(band)與or分組混用', async function() {
255
+ let serieses = { sig: buildArrSig().map((v, i) => ({ ...v, param: [0.5, 0.9, 0.6, 0.1, 0.4, 0.2][i] })) }
256
+ let conds = [
257
+ { key: 'sig', sym: '>', th: 0.3, opr: 'and' },
258
+ { key: 'sig', sym: '<', th: 0.7, opr: 'and' },
259
+ { key: 'sig', sym: '>', th: 0.45, opr: 'or' },
260
+ ]
261
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.02, sl: 0.03, mode: 'short' }
262
+ await assertCase(cs, '邊角7b')
263
+ })
264
+
265
+ it('邊角8: uTradeAllMax峰值發生於中途進場點(僅於timeStart取樣)', async function() {
266
+ //t00/t04/t08連續進場且持單重疊(tp/sl大到窗內不觸線之前先重疊) → 峰值300於t08
267
+ let serieses = { sig: buildArrSig().map((v, i) => ({ ...v, param: i <= 2 ? 1 : 0 })) }
268
+ let conds = [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }]
269
+ let cs = { arrOhlc: buildArrOhlc(), serieses, conds, tp: 0.5, sl: 0.5, mode: 'long' }
270
+ let { smFast } = await assertCase(cs, '邊角8')
271
+ assert.strictEqual(smFast.numTrade, 3)
272
+ assert.strictEqual(smFast.uTradeAllMax, 300) //三單同時在倉, 峰值於第三單timeStart
273
+ })
274
+
275
+ it('conds空陣列或cond.key不在serieses時throw(同runStrategy)', function() {
276
+ let session = buildStrategyFastSession(ott, {
277
+ arrOhlc: buildArrOhlc(),
278
+ mode: 'long',
279
+ serieses: { sig: buildArrSig() },
280
+ })
281
+ assert.throws(() => session.evaluate({ conds: [], tp: 0.05, sl: 0.03, settings: st }), /invalid conds/)
282
+ assert.throws(() => session.evaluate({ conds: [{ key: 'nope', sym: '>', th: 0.5 }], tp: 0.05, sl: 0.03, settings: st }), /not in opt\.serieses/)
283
+ })
284
+
285
+ it('opt.tpsls預建與惰性建置結果一致', function() {
286
+ let build = (tpsls) => buildStrategyFastSession(ott, {
287
+ arrOhlc: buildArrOhlc(),
288
+ mode: 'long',
289
+ serieses: { sig: buildArrSig() },
290
+ tpsls,
291
+ })
292
+ let input = { conds: [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }], tp: 0.05, sl: 0.03, settings: st }
293
+ let smPre = build([{ tp: 0.05, sl: 0.03 }]).evaluate(input)
294
+ let smLazy = build([]).evaluate(input)
295
+ assert.deepStrictEqual(smPre, smLazy)
296
+ })
297
+
298
+ })