import each from 'lodash-es/each.js'
import first from 'lodash-es/first.js'
import get from 'lodash-es/get.js'
import last from 'lodash-es/last.js'
import size from 'lodash-es/size.js'
import arrSort from 'wsemi/src/arrSort.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import pmSeries from 'wsemi/src/pmSeries.mjs'
import calcOrders from './calcOrders.mjs'
import calcOrdersRatio from './calcOrdersRatio.mjs'
import calcOrdersSummary from './calcOrdersSummary.mjs'
/**
* 執行單一策略回測
*
* 於各時間點以conds判斷觸發下單(各條件以sym與th比較該key序列之param值,opr為'and'須全真、'or'至少一真)
* 觸發時以該K棒Close下單,並依settings計算止盈止損價格,再經calcOrders結算與calcOrdersRatio、calcOrdersSummary統計
* 僅於全部conds序列皆有值之共同時間點判斷觸發
* strategy.mode非'long'或'short'、keyOhlc或conds無效、funGetSeries非函數時reject
*
* Unit Test: {@link https://github.com/yuda-lyu/w-trade-backtest/blob/master/test/unit-WTradeBacktest.test.mjs Github}
* @function
* @param {Function} ott 輸入時區時間函數,傳入時間字串回傳dayjs時間物件(可用src/ott.mjs或自行以dayjs包裝)
* @param {Object} strategy 輸入策略物件,需含mode('long'或'short')、keyOhlc(K線序列key字串)、conds(條件陣列,各元素為{key,sym,th,opr})、settings({uIni,uTrade,rTakeProfit,rStopLoss,rFee})欄位
* @param {Function} funGetSeries 輸入序列查詢async函數,依key回傳時間序列陣列
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Boolean} [opt.withCalcOrderProfitOrLoss=true] 輸入是否經calcOrders結算訂單布林值,預設true
* @param {Boolean} [opt.withSummary=true] 輸入是否計算統計摘要布林值,預設true
* @returns {Promise} 回傳Promise,resolve為{orders,summary}物件
* @example
*
* let arrOhlc = [
* { time: '2020-01-01T00:00:00', Open: 100, High: 101, Low: 99, Close: 100 },
* { time: '2020-01-01T04:00:00', Open: 100, High: 106, Low: 100, Close: 105 },
* { time: '2020-01-01T08:00:00', Open: 105, High: 107, Low: 102, Close: 103 },
* { time: '2020-01-01T12:00:00', Open: 103, High: 104, Low: 94, Close: 95 },
* { time: '2020-01-01T16:00:00', Open: 95, High: 98, Low: 92, Close: 93 },
* { time: '2020-01-01T20:00:00', Open: 93, High: 99, Low: 95, Close: 97 },
* ]
*
* let arrSig = [
* { time: '2020-01-01T00:00:00', param: 1 },
* { time: '2020-01-01T04:00:00', param: 0 },
* { time: '2020-01-01T08:00:00', param: 1 },
* { time: '2020-01-01T12:00:00', param: 0 },
* { time: '2020-01-01T16:00:00', param: 0 },
* { time: '2020-01-01T20:00:00', param: 0 },
* ]
*
* let funGetSeries = async (key) => {
* if (key === 'btc') {
* return arrOhlc
* }
* if (key === 'sig') {
* return arrSig
* }
* throw new Error(`invalid key[${key}]`)
* }
*
* let strategy = {
* mode: 'long',
* keyOhlc: 'btc',
* conds: [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }],
* settings: { uIni: 1000, uTrade: 100, rTakeProfit: 0.05, rStopLoss: 0.03, rFee: 0.0005 },
* }
*
* let r = await runStrategy(ott, strategy, funGetSeries)
* console.log(r.orders.map((o) => `${o.timeStart} ${o.mode} ${o.priceStart}->${o.priceEnd} ${o.modeResult}`))
* // => [
* // '2020-01-01T00:00:00 long 100->105 profit',
* // '2020-01-01T08:00:00 long 103->99.91 loss'
* // ]
* console.log(r.summary.numTrade, r.summary.rWin, r.summary.uEquityFinal)
* // => 2 50.00% 1001.8
*
*/
let runStrategy = async(ott, strategy, funGetSeries, opt = {}) => {
//mode
let mode = get(strategy, 'mode', '')
if (mode !== 'long' && mode !== 'short') {
throw new Error(`invalid strategy.mode[${mode}] not 'long' or 'short'`)
}
//keyOhlc
let keyOhlc = get(strategy, 'keyOhlc', '')
if (!isestr(keyOhlc)) {
throw new Error(`invalid strategy.keyOhlc`)
}
//conds
let conds = get(strategy, 'conds', [])
if (!isearr(conds)) {
throw new Error(`invalid strategy.conds`)
}
//nps
let nps = size(conds)
// console.log('nps', nps)
//st
let st = get(strategy, 'settings', {})
if (!iseobj(st)) {
st = {
uIni: 1000, //初始資金(USDT)
uTrade: 1, //每次下單金額(USDT)
rTakeProfit: 0.05, //止盈
rStopLoss: 0.03, //止損
rFee: 0.0005, //手續費
}
}
// console.log('st', st)
//check
if (!isfun(funGetSeries)) {
throw new Error(`funGetSeries is not a function`)
}
//withCalcOrderProfitOrLoss
let withCalcOrderProfitOrLoss = get(opt, 'withCalcOrderProfitOrLoss', true)
//withSummary
let withSummary = get(opt, 'withSummary', true)
//arrOhlc
let arrOhlc = await funGetSeries(keyOhlc)
// console.log('arrOhlc', arrOhlc, last(arrOhlc))
//因conds需要各時間點都有值才能做交集判斷, 所以須提取各conds所需數據且皆為同起訖時間之長度
let kpTimeNum = {} //儲存各數據之全部數據時間, 若出現相同數據時間就+1, 若該時間內累計數字等同於數據種數, 就代表該時間為全部數據皆有之時間
let kpArrParams = {} //儲存各數據
await pmSeries(conds, async (p) => {
//arr
let arr = await funGetSeries(p.key)
// console.log('arr', arr)
//default
if (!haskey(kpArrParams, p.key)) {
kpArrParams[p.key] = {}
}
//save
each(arr, (v) => {
//v.time
//v.param
//save param
kpArrParams[p.key][v.time] = v.param
//default
if (!haskey(kpTimeNum, v.time)) {
kpTimeNum[v.time] = 0
}
//add 1
kpTimeNum[v.time] += 1
})
// console.log('kpArrParams', kpArrParams)
// console.log('kpTimeNum', kpTimeNum)
})
//timeEff, 各數據皆有共同之數據時間, 為有效時間陣列
let timeEff = []
if (true) {
each(kpTimeNum, (n, time) => {
if (n === nps) {
timeEff.push(time)
}
})
timeEff = arrSort(timeEff)
// console.log('timeEff', timeEff)
}
//kpTimeEff, 轉為字典物件用於後續判斷
let kpTimeEff = {}
if (true) {
each(timeEff, (time) => {
kpTimeEff[time] = true
})
}
//kpParams, 儲存各有效時間之各數據
let kpParams = {}
if (true) {
each(timeEff, (time) => {
kpParams[time] = {}
each(conds, (p) => {
kpParams[time][p.key] = kpArrParams[p.key][time]
})
})
// console.log('kpParams', kpParams)
}
//timeOhlcStart, timeOhlcEnd
let timeOhlcStart = get(first(arrOhlc), 'time', '')
let timeOhlcEnd = get(last(arrOhlc), 'time', '')
// console.log('timeOhlcStart', timeOhlcStart)
// console.log('timeOhlcEnd', timeOhlcEnd)
//orders
let orders = []
await pmSeries(arrOhlc, async(v) => {
// console.log('v', v)
//time
let time = get(v, 'time')
// console.log('time', time)
//check
if (!haskey(kpTimeEff, time)) {
return true //跳出換下一個
}
// console.log('time', time)
//bTrigger
let bTrigger = true
if (true) {
//bs
let bs = []
each(conds, (cond, kcond) => {
//y, 儲存值
let y = kpParams[time][cond.key]
// console.log(time, cond.key, 'y', y)
//sym, th, opr
let sym = get(cond, 'sym', '>')
let th = get(cond, 'th', 0)
let opr = get(cond, 'opr', null)
if (opr !== 'and' && opr !== 'or') {
opr = 'and'
}
// console.log(time, cond.key, 'sym', sym)
// console.log(time, cond.key, 'th', th)
// console.log(time, cond.key, 'opr', opr)
//b
let b = false
if (sym === '>') {
b = y > th
}
else {
b = y < th
}
//push
bs.push({
opr,
b,
})
})
//依各 cond 的 opr 分組合成 bTrigger:
// 'and' 組 → 全部須為 true(必要條件); 'or' 組 → 至少一個 true(選擇條件, 無 or 組則不約束)
// bTrigger = (所有 and 條件皆真) AND (任一 or 條件為真 或 根本沒有 or 條件)
let okAnd = bs.filter((v) => v.opr === 'and').every((v) => v.b) //空 and 組 → every 回 true(無約束)
let bsOr = bs.filter((v) => v.opr === 'or')
let okOr = bsOr.length === 0 || bsOr.some((v) => v.b) //沒有 or 條件 → 不約束; 有則至少一個須真
bTrigger = okAnd && okOr
}
let bLong = false
let bShort = false
if (bTrigger) {
if (mode === 'long') {
bLong = true
}
else { //'short'
bShort = true
}
}
if (bLong) { //做多
// console.log('long...', time, v.Close)
let o = {
mode: 'long',
timeStart: time, //下單買入時所屬k棒時間
priceStart: v.Close, //單買入時Close價格
uTrade: st.uTrade, //下單金額(USDT)
rTakeProfit: st.rTakeProfit, //下單設定止盈比例
priceTakeProfit: (1 + st.rTakeProfit) * v.Close, //下單設定止盈價格(USDT)
rStopLoss: st.rStopLoss, //下單設定止損比例
priceStopLoss: (1 - st.rStopLoss) * v.Close, //下單設定止損價格(USDT)
timeEnd: '', //賣出時所屬k棒時間, 待最後結算更新
priceEnd: '', //賣出時Close價格, 待最後結算更新
modeResult: '', //交易結果, 止盈(takeProfit)或止損(stopLoss), 待最後結算更新
rFee: st.rFee, //手續費比例
uFee: st.rFee * st.uTrade, //交易手續費(USDT)
uProfitOrLoss: '', //盈虧金額(USDT), 含手續費, 待最後結算更新
rProfitOrLoss: '', //盈虧比例, 待最後結算更新
uCumuProfitOrLoss: '', //累計盈虧金額(USDT), 含手續費, 待最後結算更新
rCumuProfitOrLoss: '', //累計盈虧比例(%), 待最後結算更新
uEquity: '', //當前權益金額(USDT), 待最後結算更新
uTradeAll: '', //當前持倉金額(USDT), 待最後結算更新
rTradeAll: '', //當前持倉金額佔比(%), 待最後結算更新
}
orders.push(o)
}
else if (bShort) { //做空
// console.log('short...', time, v.Close)
let o = {
mode: 'short',
timeStart: time, //賣出時所屬k棒時間
priceStart: v.Close, //賣出時Close價格
uTrade: st.uTrade, //下單金額(USDT)
rTakeProfit: st.rTakeProfit, //止盈
priceTakeProfit: (1 - st.rTakeProfit) * v.Close, //止盈價格(USDT)
rStopLoss: st.rStopLoss, //止損
priceStopLoss: (1 + st.rStopLoss) * v.Close, //止損價格(USDT)
timeEnd: '', //買入時所屬k棒時間, 待最後結算更新
priceEnd: '', //買入時Close價格, 待最後結算更新
modeResult: '', //交易結果, 止盈(takeProfit)或止損(stopLoss), 待最後結算更新
rFee: st.rFee, //手續費比例
uFee: st.rFee * st.uTrade, //交易手續費(USDT)
uProfitOrLoss: '', //盈虧金額(USDT), 含手續費, 待最後結算更新
rProfitOrLoss: '', //盈虧比例, 待最後結算更新
uCumuProfitOrLoss: '', //累計盈虧金額(USDT), 含手續費, 待最後結算更新
rCumuProfitOrLoss: '', //累計盈虧比例(%), 待最後結算更新
uEquity: '', //當前權益金額(USDT), 待最後結算更新
uTradeAll: '', //當前持倉金額(USDT), 待最後結算更新
rTradeAll: '', //當前持倉金額佔比(%), 待最後結算更新
}
orders.push(o)
}
})
// console.log('orders', orders)
// console.log('size(orders)', size(orders))
//withCalcOrderProfitOrLoss
if (withCalcOrderProfitOrLoss) {
orders = await calcOrders(arrOhlc, orders, { uIni: st.uIni })
// console.log('orders', orders)
}
//summary
let summary = {
timeTest: ott().format('YYYY-MM-DDTHH:mm:ssZ'),
timeOhlcStart,
timeOhlcEnd,
uIni: get(st, 'uIni', ''), //額外提取uIni儲存至summary, 其他參數須依照單策略或多策略自行再添加
}
//withSummary
if (withSummary) {
//計算各單持倉天數, 等效日盈虧比例
orders = calcOrdersRatio(ott, orders)
//評分計算
let _summary = calcOrdersSummary(ott, st.uIni, orders, timeOhlcStart, timeOhlcEnd)
//merge
summary = {
...summary,
..._summary,
}
}
//r
let r = {
orders,
summary,
}
// console.log('r', r)
return r
}
export default runStrategy