w-trade-solve 1.0.5 → 2.0.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.
@@ -4,7 +4,9 @@ import path from 'path'
4
4
  import estimKeys from '../src/estimKeys.mjs'
5
5
  import readStrategies from '../src/readStrategies.mjs'
6
6
  import genTid from '../src/genTid.mjs'
7
- import cont from '../src/cont.mjs'
7
+ import genStrategyFileName from '../src/genStrategyFileName.mjs'
8
+ import calcLevelNumTrade from '../src/calcLevelNumTrade.mjs'
9
+ import calcFitness from '../src/calcFitness.mjs'
8
10
  import ott from '../src/ott.mjs'
9
11
  import { buildFdTmp, buildFdData, name, symbol, interval } from './unit-setup.mjs'
10
12
 
@@ -15,7 +17,10 @@ import { buildFdTmp, buildFdData, name, symbol, interval } from './unit-setup.mj
15
17
  // 各key門檻由rang(-2,2,40)之41點各自平移+10與-10為82點, 解值>0代表'>'條件、<0代表'<'條件, 還原時扣除平移值
16
18
  // 以opt.methodOml指定之演算法求解(預設PSO), 各次以runStrategy回測並經calcFitness計算適應值
17
19
  // 滿足thNumTrade、thRWin與thREquivalentCumuProfitOrLossFinalNormYear三門檻者, 以genStrategyFileName存入fdData
18
- // 同tkid(tid+級距)已有策略時, 僅於等效年化盈虧更佳時抽換並刪除較差者, 故同tkid恆僅一檔
20
+ // 檔名為tkid之純函數(雜湊格式), 同tkid已有策略時僅於等效年化盈虧更佳時原地覆寫, 故同tkid恆僅一檔
21
+ // 策略檔內容除策略本體與summary、fitness外, 另落地tkid、numTrade、levelNumTrade、keys與solve區塊使欄位自足
22
+ // opt.funRunStrategyCustom可注入自訂評估核心(回傳{summary}), 求解全程改用之
23
+ // opt.useRunStrategyBeforeSave為true時存檔前重跑回測, 檔內numTrade/levelNumTrade/tkid/fitness皆由重跑之summary重新衍生(genRes同源)
19
24
  // fdData不存在時自動建立
20
25
 
21
26
 
@@ -116,6 +121,34 @@ describe('estimKeys', function() {
116
121
  })
117
122
  })
118
123
 
124
+ it('策略檔內容欄位自足: 含tkid、numTrade、levelNumTrade與keys, 識別不依賴檔名', function() {
125
+ let ss = readStrategies(d.fdData, { readContent: true })
126
+ ss.forEach((s) => {
127
+ let c = s.data
128
+ assert.strictEqual(c.numTrade, Number(c.summary.numTrade))
129
+ assert.strictEqual(c.levelNumTrade, calcLevelNumTrade(Number(c.summary.numTrade)))
130
+ assert.strictEqual(c.tkid, `${c.tid}:${c.levelNumTrade}`)
131
+ assert.deepStrictEqual(c.keys, keys)
132
+ assert.strictEqual(c.fitness, calcFitness(c.settings, c.summary)) //fitness與summary同源
133
+ })
134
+ })
135
+
136
+ it('策略檔內容含solve區塊, 記錄求解時間窗、演算法與三門檻等求解條件', function() {
137
+ let ss = readStrategies(d.fdData, { readContent: true })
138
+ ss.forEach((s) => {
139
+ let c = s.data
140
+ assert.strictEqual(c.solve.timeStart, d.timeStart)
141
+ assert.strictEqual(c.solve.timeEnd, d.timeEnd)
142
+ assert.strictEqual(c.solve.methodOml, 'PSO') //未給methodOml時採PSO, 記錄實際採用者
143
+ assert.strictEqual(c.solve.thNumTrade, optBase.thNumTrade)
144
+ assert.strictEqual(c.solve.thRWin, optBase.thRWin)
145
+ assert.strictEqual(c.solve.thREquivalentCumuProfitOrLossFinalNormYear, optBase.thREquivalentCumuProfitOrLossFinalNormYear)
146
+ assert.deepStrictEqual(c.solve.thsTp, optBase.thsTp)
147
+ assert.deepStrictEqual(c.solve.thsSl, optBase.thsSl)
148
+ assert.match(c.solve.timeSolved, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
149
+ })
150
+ })
151
+
119
152
  it('策略檔內之conds各元素為{key,sym,th}, key取自keys, sym為>或<, th落於[-2,2]', function() {
120
153
  let ss = readStrategies(d.fdData, { readContent: true })
121
154
  ss.forEach((s) => {
@@ -173,6 +206,191 @@ describe('estimKeys', function() {
173
206
 
174
207
  })
175
208
 
209
+ describe('自訂評估核心funRunStrategyCustom', function() {
210
+
211
+ let keys = ['btc_4hr_ma_1day']
212
+ let d = null
213
+
214
+ before(function() {
215
+ d = buildFdData(fdTmp, 'custom', keys, { n: 20 })
216
+ })
217
+
218
+ //optQuick: 縮小求解量之共用設定
219
+ let optQuick = {
220
+ ...optBase,
221
+ Np: 8,
222
+ NContiguous: 5,
223
+ UseRepeat: false,
224
+ UseImmigration: false,
225
+ }
226
+
227
+ //smStub: 過三門檻之固定精簡summary(含契約要求之numTrade/rWin/uEquityFinal/eqYr四欄)
228
+ let smStub = {
229
+ numTrade: 10,
230
+ rWin: '60.00%',
231
+ uEquityFinal: 1005,
232
+ rEquivalentCumuProfitOrLossFinalNormYear: '20.00%',
233
+ rTradeAllMax: '1.00%',
234
+ }
235
+
236
+ //call: 以獨立策略資料夾執行求解並讀回策略檔
237
+ let call = async (tag, extra = {}) => {
238
+ let fdData = path.resolve(fdTmp, 'custom', `s-${tag}`)
239
+ let m = await estimKeys(ott, name, symbol, interval, d.fdOhlc, d.fdParam, d.timeStart, d.timeEnd, 'long', keys, fdData, {
240
+ ...optQuick,
241
+ ...extra,
242
+ })
243
+ let ss = readStrategies(fdData, { readContent: true })
244
+ return { m, ss }
245
+ }
246
+
247
+ it('注入後求解全程改用自訂函數, 並收到{ott,strategy,funGetSeries}', async function() {
248
+ let nCall = 0
249
+ let gotShape = false
250
+ let { m, ss } = await call('inject', {
251
+ funRunStrategyCustom: async ({ ott, strategy, funGetSeries }) => {
252
+ nCall++
253
+ if (!gotShape) {
254
+ gotShape = typeof ott === 'function' && typeof funGetSeries === 'function' && Array.isArray(strategy.conds) && strategy.mode === 'long'
255
+ }
256
+ return { summary: smStub }
257
+ },
258
+ })
259
+ assert.ok(nCall > 0, '自訂函數未被呼叫')
260
+ assert.strictEqual(nCall, m.stopExecutions, '自訂函數呼叫次數須等於核心分析次數')
261
+ assert.ok(gotShape, '自訂函數收到之引數形式非預期')
262
+ assert.strictEqual(ss.length, 1, 'stub固定summary故同tkid恆僅一檔')
263
+ })
264
+
265
+ it('預設不重跑, 存檔即為自訂函數之summary, 衍生欄位與之同源', async function() {
266
+ let { ss } = await call('nosave-rerun', {
267
+ funRunStrategyCustom: async () => {
268
+ return { summary: smStub }
269
+ },
270
+ })
271
+ let c = ss[0].data
272
+ assert.deepStrictEqual(c.summary, smStub)
273
+ assert.strictEqual(c.numTrade, 10)
274
+ assert.strictEqual(c.levelNumTrade, calcLevelNumTrade(10))
275
+ assert.strictEqual(c.tkid, `${c.tid}:${calcLevelNumTrade(10)}`)
276
+ assert.strictEqual(c.fitness, calcFitness(c.settings, smStub))
277
+ })
278
+
279
+ it('useRunStrategyBeforeSave為true時存檔前重跑runStrategy, 落地完整官方summary且全部衍生欄位同源', async function() {
280
+ //stub僅首次評估放行門檻: 官方重跑之numTrade級距與stub可能不同, 若後續評估再查同檔會觸發tkid不一致throw(該防護屬預期行為), 故僅存檔一次
281
+ let nCall = 0
282
+ let { ss } = await call('save-rerun', {
283
+ funRunStrategyCustom: async () => {
284
+ nCall++
285
+ if (nCall === 1) {
286
+ return { summary: smStub }
287
+ }
288
+ return { summary: { ...smStub, numTrade: 0 } } //不過thNumTrade門檻
289
+ },
290
+ useRunStrategyBeforeSave: true,
291
+ })
292
+ assert.ok(ss.length >= 1)
293
+ ss.forEach((s) => {
294
+ let c = s.data
295
+ //官方summary具stub沒有的完整欄位
296
+ assert.ok('rSharpe' in c.summary, '落地summary須為官方完整欄位')
297
+ assert.ok('rDrawdownMax' in c.summary)
298
+ //檔內衍生欄位由重跑之summary重新衍生(genRes同源)
299
+ assert.strictEqual(c.numTrade, Number(c.summary.numTrade))
300
+ assert.strictEqual(c.levelNumTrade, calcLevelNumTrade(Number(c.summary.numTrade)))
301
+ assert.strictEqual(c.tkid, `${c.tid}:${c.levelNumTrade}`)
302
+ assert.strictEqual(c.fitness, calcFitness(c.settings, c.summary))
303
+ })
304
+ })
305
+
306
+ it('funRunStrategyCustomBeforeSave給定時, 存檔前改用該函數', async function() {
307
+ let smSave = { ...smStub, rEquivalentCumuProfitOrLossFinalNormYear: '33.00%', numTrade: 12 }
308
+ let { ss } = await call('save-custom', {
309
+ funRunStrategyCustom: async () => {
310
+ return { summary: smStub }
311
+ },
312
+ useRunStrategyBeforeSave: true,
313
+ funRunStrategyCustomBeforeSave: async () => {
314
+ return { summary: smSave }
315
+ },
316
+ })
317
+ let c = ss[0].data
318
+ assert.deepStrictEqual(c.summary, smSave)
319
+ assert.strictEqual(c.numTrade, 12) //由重跑summary重新衍生
320
+ assert.strictEqual(c.fitness, calcFitness(c.settings, smSave))
321
+ })
322
+
323
+ it('重跑結果跨級距時, 檔名以重跑summary重算, 檔名與檔內同源', async function() {
324
+ //stub級距11(numTrade 10), 重跑回傳級距12(numTrade 30) → 檔案應落在級距12檔名
325
+ let smSave = { ...smStub, numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '33.00%' }
326
+ let nCall = 0
327
+ let { ss } = await call('save-relevel', {
328
+ funRunStrategyCustom: async () => {
329
+ nCall++
330
+ return { summary: nCall === 1 ? smStub : { ...smStub, numTrade: 0 } } //僅首次放行, 避免後續評估以級距11舊檔名再查
331
+ },
332
+ useRunStrategyBeforeSave: true,
333
+ funRunStrategyCustomBeforeSave: async () => {
334
+ return { summary: smSave }
335
+ },
336
+ })
337
+ assert.strictEqual(ss.length, 1)
338
+ assert.match(ss[0].name, /^long_12_[0-9a-f]{16}\.json$/, `檔名 ${ss[0].name} 須為重跑之級距12`)
339
+ let c = ss[0].data
340
+ assert.strictEqual(c.numTrade, 30)
341
+ assert.strictEqual(c.levelNumTrade, 12)
342
+ assert.strictEqual(c.tkid, `${c.tid}:12`)
343
+ })
344
+
345
+ it('跨級距且新檔名已有更佳者時, 放棄本次寫入', async function() {
346
+ //預置級距12檔案(eqYr 50%), 重跑回傳級距12但eqYr 33%較差 → 不得覆寫
347
+ let fdData = path.resolve(fdTmp, 'custom', 's-relevel-keep')
348
+ fs.mkdirSync(fdData, { recursive: true })
349
+ let tid = genTid(name, '4hr', 'long', keys)
350
+ let smBetter = { numTrade: 30, rWin: '70.00%', uEquityFinal: 1010, rEquivalentCumuProfitOrLossFinalNormYear: '50.00%' }
351
+ let fnBetter = genStrategyFileName(name, '4hr', 'long', keys, {}, smBetter)
352
+ fs.writeFileSync(path.resolve(fdData, fnBetter), JSON.stringify({
353
+ tid,
354
+ tkid: `${tid}:12`,
355
+ name,
356
+ interval: '4hr',
357
+ summary: smBetter,
358
+ }), 'utf8')
359
+
360
+ let smSave = { ...smStub, numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '33.00%' }
361
+ let nCall = 0
362
+ await estimKeys(ott, name, symbol, interval, d.fdOhlc, d.fdParam, d.timeStart, d.timeEnd, 'long', keys, fdData, {
363
+ ...optQuick,
364
+ funRunStrategyCustom: async () => {
365
+ nCall++
366
+ return { summary: nCall === 1 ? smStub : { ...smStub, numTrade: 0 } }
367
+ },
368
+ useRunStrategyBeforeSave: true,
369
+ funRunStrategyCustomBeforeSave: async () => {
370
+ return { summary: smSave }
371
+ },
372
+ })
373
+
374
+ let ss = readStrategies(fdData, { readContent: true })
375
+ assert.strictEqual(ss.length, 1, '不得產生新檔')
376
+ assert.strictEqual(ss[0].data.summary.rEquivalentCumuProfitOrLossFinalNormYear, '50.00%', '既有更佳者不得被覆寫')
377
+ })
378
+
379
+ it('未開useRunStrategyBeforeSave時, funRunStrategyCustomBeforeSave不生效', async function() {
380
+ let smSave = { ...smStub, rEquivalentCumuProfitOrLossFinalNormYear: '33.00%' }
381
+ let { ss } = await call('save-custom-off', {
382
+ funRunStrategyCustom: async () => {
383
+ return { summary: smStub }
384
+ },
385
+ funRunStrategyCustomBeforeSave: async () => {
386
+ return { summary: smSave }
387
+ },
388
+ })
389
+ assert.deepStrictEqual(ss[0].data.summary, smStub)
390
+ })
391
+
392
+ })
393
+
176
394
  describe('omlPSO設定傳遞', function() {
177
395
 
178
396
  //opt除estimKeys自身欄位外皆原樣傳遞至omlPSO, 兩者欄位名稱無重疊
@@ -488,14 +706,13 @@ describe('estimKeys', function() {
488
706
 
489
707
  describe('策略檔名規則', function() {
490
708
 
491
- it('檔名恰可由dlmPkgs切為3段', function() {
709
+ it('檔名為雜湊格式`${mode}_${levelNumTrade}_${hash16}.json`, 長度固定不受keys個數影響', function() {
492
710
  //由求解階段所產生之檔案驗證
493
711
  let fdData = path.resolve(fdTmp, 'solve', 'data-strategy')
494
712
  let fns = fs.readdirSync(fdData)
495
713
  assert.ok(fns.length > 0)
496
714
  fns.forEach((fn) => {
497
- assert.strictEqual(fn.slice(0, -5).split(cont.dlmPkgs).length, 3, `檔名 ${fn}`)
498
- assert.ok(fn.endsWith('.json'), `檔名 ${fn}`)
715
+ assert.match(fn, /^long_\d+_[0-9a-f]{16}\.json$/, `檔名 ${fn}`)
499
716
  })
500
717
  })
501
718
 
@@ -1,39 +1,61 @@
1
1
  import assert from 'assert'
2
+ import crypto from 'crypto'
2
3
  import genStrategyFileName from '../src/genStrategyFileName.mjs'
3
4
  import genTid from '../src/genTid.mjs'
4
5
  import calcLevelNumTrade from '../src/calcLevelNumTrade.mjs'
5
- import cont from '../src/cont.mjs'
6
6
 
7
7
 
8
8
  //規格來源: src/genStrategyFileName.mjs
9
9
  // genStrategyFileName(coin, interval, mode, keys, settings, summary):
10
- // 檔名為`${tid}${cont.dlmPkgs}${levelNumTrade}${cont.dlmPkgs}${rEquivalentCumuProfitOrLossFinalNormYear}.json`
10
+ // 檔名為`${mode}_${levelNumTrade}_${hash}.json`
11
+ // hash為md5(`${coin}:${interval}:${tid}:${levelNumTrade}`)取前16碼(64bits)
11
12
  // tid由genTid(coin, interval, mode, keys)產生
12
13
  // levelNumTrade由calcLevelNumTrade(cint(summary.numTrade))產生
13
- // rEquivalentCumuProfitOrLossFinalNormYear直接取自summary, 未經轉換
14
+ // 檔名為(coin,interval,tid,levelNumTrade)之純函數, 與求解結果(等效年化盈虧)無關, 故同tkid恆對應同一檔名
15
+ // hash輸入含coin與interval, 使genTid剔除幣種與週期後相同之tid不致跨幣種/跨週期碰撞
14
16
  // settings未參與檔名組成
15
17
 
16
18
 
17
- let p = cont.dlmPkgs
19
+ let p = '_'
18
20
 
19
21
 
20
22
  let settings = { uIni: 1000, uTrade: 1, rTakeProfit: 0.05, rStopLoss: 0.03, rFee: 0.0005 }
21
23
 
22
24
 
25
+ //buildFn: 依規格自行組出預期檔名
26
+ let buildFn = (coin, interval, mode, keys, numTrade) => {
27
+ let tid = genTid(coin, interval, mode, keys)
28
+ let levelNumTrade = calcLevelNumTrade(numTrade)
29
+ let hash = crypto.createHash('md5').update(`${coin}:${interval}:${tid}:${levelNumTrade}`, 'utf8').digest('hex').slice(0, 16)
30
+ return `${mode}${p}${levelNumTrade}${p}${hash}.json`
31
+ }
32
+
33
+
23
34
  describe('genStrategyFileName', function() {
24
35
 
25
- it('檔名由tid、級距與等效年化盈虧以dlmPkgs串接並附.json', function() {
36
+ it('檔名為mode、級距與雜湊16碼以底線串接並附.json', function() {
26
37
  let keys = ['btc_price_4hr_ma_1day']
27
38
  let summary = { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '31.25%' }
28
39
  let fn = genStrategyFileName('btc', '4hr', 'long', keys, settings, summary)
29
- assert.strictEqual(fn, `long${cont.dlmSeps}p_ma_1day${p}12${p}31.25%.json`)
40
+ //tid為'long ║ p_ma_1day', 級距12, hash=md5('btc:4hr:long ║ p_ma_1day:12')前16碼
41
+ assert.strictEqual(fn, 'long_12_9fce6961e8f56493.json')
30
42
  })
31
43
 
32
- it('tid段與genTid之輸出一致', function() {
44
+ it('檔名符合`${mode}_${levelNumTrade}_${hash16}.json`樣式', function() {
33
45
  let keys = ['btc_index_rsi_4hr', 'btc_price_4hr_ma_1day']
34
46
  let summary = { numTrade: 8, rEquivalentCumuProfitOrLossFinalNormYear: '5%' }
35
47
  let fn = genStrategyFileName('btc', '4hr', 'short', keys, settings, summary)
36
- assert.strictEqual(fn.split(p)[0], genTid('btc', '4hr', 'short', keys))
48
+ assert.match(fn, /^short_2_[0-9a-f]{16}\.json$/)
49
+ })
50
+
51
+ it('雜湊段依規格為md5(`${coin}:${interval}:${tid}:${levelNumTrade}`)前16碼, 可由genTid與calcLevelNumTrade重組', function() {
52
+ let keys = ['btc_index_rsi_4hr', 'btc_price_4hr_ma_1day']
53
+ let nums = [0, 3, 30, 120, 3500, 9000]
54
+ nums.forEach((numTrade) => {
55
+ let summary = { numTrade, rEquivalentCumuProfitOrLossFinalNormYear: '1%' }
56
+ let fn = genStrategyFileName('btc', '4hr', 'long', keys, settings, summary)
57
+ assert.strictEqual(fn, buildFn('btc', '4hr', 'long', keys, numTrade), `numTrade[${numTrade}]`)
58
+ })
37
59
  })
38
60
 
39
61
  it('級距段與calcLevelNumTrade之輸出一致', function() {
@@ -46,11 +68,12 @@ describe('genStrategyFileName', function() {
46
68
  })
47
69
  })
48
70
 
49
- it('等效年化盈虧原樣帶入, 含負值與%符號', function() {
71
+ it('等效年化盈虧不影響檔名, 同tkid恆對應同一檔名', function() {
72
+ //此為O(1)直查與原地覆寫之基礎: 同tkid之新舊策略檔名相同
50
73
  let keys = ['aa']
51
- let summary = { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '-71.17%' }
52
- let fn = genStrategyFileName('btc', '4hr', 'long', keys, settings, summary)
53
- assert.strictEqual(fn, `long${cont.dlmSeps}aa${p}12${p}-71.17%.json`)
74
+ let a = genStrategyFileName('btc', '4hr', 'long', keys, settings, { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '10%' })
75
+ let b = genStrategyFileName('btc', '4hr', 'long', keys, settings, { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '-71.17%' })
76
+ assert.strictEqual(a, b)
54
77
  })
55
78
 
56
79
  it('settings不影響檔名', function() {
@@ -66,14 +89,30 @@ describe('genStrategyFileName', function() {
66
89
  let a = genStrategyFileName('btc', '4hr', 'long', keys, settings, { numTrade: 20, rEquivalentCumuProfitOrLossFinalNormYear: '10%' })
67
90
  let b = genStrategyFileName('btc', '4hr', 'long', keys, settings, { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '10%' })
68
91
  assert.notStrictEqual(a, b)
69
- assert.strictEqual(a.split(p)[0], b.split(p)[0]) //tid相同
70
92
  })
71
93
 
72
- it('檔名恰可由dlmPkgs切為3段', function() {
73
- let keys = ['btc_price_4hr_ma_1day', 'btc_index_rsi_4hr']
74
- let summary = { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '31.25%' }
94
+ it('不同幣種或週期之同組keys檔名不同, 不因genTid剔除幣種與週期而碰撞', function() {
95
+ //genTid('eth','4hr',...,['eth_price_4hr_ma_1day'])與genTid('btc','4hr',...,['btc_price_4hr_ma_1day'])同為'long ║ p_ma_1day',
96
+ // hash輸入含coin與interval, 故檔名仍不同
97
+ let summary = { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '10%' }
98
+ let a = genStrategyFileName('eth', '4hr', 'long', ['eth_price_4hr_ma_1day'], settings, summary)
99
+ let b = genStrategyFileName('btc', '4hr', 'long', ['btc_price_4hr_ma_1day'], settings, summary)
100
+ let c = genStrategyFileName('btc', '1hr', 'long', ['btc_price_1hr_ma_1day'], settings, summary)
101
+ assert.strictEqual(genTid('eth', '4hr', 'long', ['eth_price_4hr_ma_1day']), genTid('btc', '4hr', 'long', ['btc_price_4hr_ma_1day'])) //tid確實相同
102
+ assert.notStrictEqual(a, b)
103
+ assert.notStrictEqual(b, c)
104
+ })
105
+
106
+ it('keys個數多且名稱長時檔名長度仍固定, 不受NTFS單檔名段255字元上限影響', function() {
107
+ //舊式檔名隨keys個數線性成長, 7因子已達約240字元; 雜湊檔名恆為`${mode}_${級距}_${16碼}.json`
108
+ let keys = []
109
+ for (let i = 0; i < 20; i++) {
110
+ keys.push(`btc_price_4hr_someVeryLongIndicatorName${i}_15day`)
111
+ }
112
+ let summary = { numTrade: 30, rEquivalentCumuProfitOrLossFinalNormYear: '10%' }
75
113
  let fn = genStrategyFileName('btc', '4hr', 'long', keys, settings, summary)
76
- assert.strictEqual(fn.split(p).length, 3)
114
+ assert.match(fn, /^long_12_[0-9a-f]{16}\.json$/)
115
+ assert.ok(fn.length <= 40, `檔名長度 ${fn.length} 須固定短`)
77
116
  })
78
117
 
79
118
  it('summary.numTrade非數值時先經cint轉為0, 級距為0', function() {