calcOrdersSummary.mjs

import each from 'lodash-es/each.js'
import filter from 'lodash-es/filter.js'
import get from 'lodash-es/get.js'
import isNumber from 'lodash-es/isNumber.js'
import map from 'lodash-es/map.js'
import size from 'lodash-es/size.js'
import sortBy from 'lodash-es/sortBy.js'
import dig from 'wsemi/src/dig.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'


/**
 * 統計全部訂單之回測摘要
 *
 * 統計交易次數、勝率、最大回撤、最大持倉、夏普值、最終權益與各盈虧比例等欄位
 * 訂單需先經calcOrders結算(含uEquity)與calcOrdersRatio計算(含dayHold與rProfitOrLossDay,夏普值計算所需)
 * uIni非正數或timeOhlcStart、timeOhlcEnd非有效字串時throw
 *
 * 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 {Number} uIni 輸入初始資金正數
 * @param {Array} ordersAll 輸入已結算訂單陣列
 * @param {String} timeOhlcStart 輸入回測起始秒時間字串
 * @param {String} timeOhlcEnd 輸入回測結束秒時間字串
 * @returns {Object} 回傳回測摘要物件
 * @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 },
 * ]
 *
 * //ordersSubmit, 4張下單: 前3張分別於後續K棒觸發止盈或止損, 第4張未觸發(未平倉)
 * let kpDef = { uTrade: 100, timeEnd: '', priceEnd: '', modeResult: '', uFee: 0.05 }
 * let ordersSubmit = [
 *     { mode: 'long', timeStart: '2020-01-01T00:00:00', priceStart: 100, priceTakeProfit: 105, priceStopLoss: 97, ...kpDef },
 *     { mode: 'long', timeStart: '2020-01-01T04:00:00', priceStart: 105, priceTakeProfit: 110.25, priceStopLoss: 101.85, ...kpDef },
 *     { mode: 'short', timeStart: '2020-01-01T08:00:00', priceStart: 103, priceTakeProfit: 97.85, priceStopLoss: 106.09, ...kpDef },
 *     { mode: 'long', timeStart: '2020-01-01T16:00:00', priceStart: 93, priceTakeProfit: 105, priceStopLoss: 85, ...kpDef },
 * ]
 *
 * let orders = await calcOrders(arrOhlc, ordersSubmit, { uIni: 1000 })
 * orders = calcOrdersRatio(ott, orders)
 * let summary = calcOrdersSummary(ott, 1000, orders, '2020-01-01T00:00:00', '2020-01-01T20:00:00')
 * console.log(summary)
 * // => {
 * //   btDays: 0,
 * //   btYears: '0.0',
 * //   numTrade: 4,
 * //   numTradeFin: 3,
 * //   numTradeUnsettled: 1,
 * //   uTradeAllMax: 200,
 * //   rTradeAllMax: '20.00%',
 * //   numTradeAllMax: 2,
 * //   uDrawdownMax: 3.1000000000000227,
 * //   rDrawdownMax: '0.31%',
 * //   rEquivalentDrawdownMax: '1.51%',
 * //   rSharpe: 7.675814289051032,
 * //   rWin: '66.67%',
 * //   uEquityFinal: 1006.6999999999999,
 * //   uCumuProfitOrLossFinal: 6.700000000000006,
 * //   rCumuProfitOrLossFinal: '0.67%',
 * //   rCumuProfitOrLossFinalNormYear: '0.00%',
 * //   rEquivalentCumuProfitOrLossFinal: '3.35%',
 * //   rEquivalentCumuProfitOrLossFinalNormYear: '0.00%'
 * // }
 *
 */
let calcOrdersSummary = (ott, uIni, ordersAll, timeOhlcStart, timeOhlcEnd) => {

    if (!ispnum(uIni)) {
        throw new Error(`uIni[${uIni}] is not a positive number`)
    }
    if (!isestr(timeOhlcStart)) {
        throw new Error(`invalid timeOhlcStart[${timeOhlcStart}]`)
    }
    if (!isestr(timeOhlcEnd)) {
        throw new Error(`invalid timeOhlcEnd[${timeOhlcEnd}]`)
    }

    //sortBy, 已或未平倉訂單一定有timeStart欄位
    ordersAll = sortBy(ordersAll, 'timeStart')

    let ordersFin = []
    let ordersUnsettled = []
    each(ordersAll, (o) => {
        if (isestr(o.modeResult)) {
            ordersFin.push(o)
        }
        else {
            ordersUnsettled.push(o)
        }
    })

    let numTrade = size(ordersAll) //總交易次數
    let numTradeFin = size(ordersFin) //總已完成交易次數
    let numTradeUnsettled = size(ordersUnsettled) //總未完成交易次數
    let rWin = dig(size(filter(ordersAll, { modeResult: 'profit' })) / numTradeFin * 100, 2) + '%' //單位為%
    let uDrawdownMax = 0 //最大回撤金額(USDT)
    let rDrawdownMax = '' //最大回撤比例(%)
    let rEquivalentDrawdownMax = '' //等效最大回撤比例(%)
    let uEquityFinal = 0 //最終權益(USDT)
    let uCumuProfitOrLossFinal = '' //最終盈虧(USDT)
    let rCumuProfitOrLossFinal = '' //最終盈虧比例(%)
    let rEquivalentCumuProfitOrLossFinal = '' //最終等效盈虧比例(%)
    let uTradeAllMax = 0 //最大持倉金額(USDT)
    let rTradeAllMax = '' //最大持倉比例(%)
    let numTradeAllMax = 0 //最大持倉筆數(同時持有的部位數)
    let rSharpe = 0 //夏普值
    let uMax = uIni //最大盈虧(USDT)
    let uMaxMax = uIni //最大回撤時之最大盈虧(USDT)

    //kpTrade(持倉金額變化), kpCount(持倉筆數變化)
    let kpTrade = {}
    let kpCount = {}
    each(ordersAll, (o) => {

        //須已平倉單才能計算最大回撤
        if (isestr(o.modeResult)) {

            //uMax
            uMax = Math.max(uMax, o.uEquity)

            //uDrawdownMax, rDrawdownMax
            let uDrawdown = uMax - o.uEquity
            let rDrawdown = dig(uDrawdown / uMax * 100, 2) + '%' //單位為%
            if (uDrawdown > uDrawdownMax) {
                uDrawdownMax = uDrawdown
                uMaxMax = uMax
                rDrawdownMax = rDrawdown
            }

        }

        //已或未平倉皆可計算最大持倉
        if (true) {

            //dir, 持倉方向
            let dir = null
            // dir = o.mode === 'long' ? 1 : -1 //long為正而short為負, 可抵銷
            dir = 1 //long與short皆為1(皆會增加持倉)
            // console.log('o.mode', o.mode, dir)

            //下單就增加持倉
            if (isestr(o.timeStart)) {
                if (!haskey(kpTrade, o.timeStart)) {
                    kpTrade[o.timeStart] = 0
                }
                kpTrade[o.timeStart] += dir * o.uTrade
                // console.log('o.timeStart', kpTrade[o.timeStart])
                if (!haskey(kpCount, o.timeStart)) {
                    kpCount[o.timeStart] = 0
                }
                kpCount[o.timeStart] += 1 //開倉筆數+1
            }

            //結單就減少持倉
            if (isestr(o.timeEnd)) {
                if (!haskey(kpTrade, o.timeEnd)) {
                    kpTrade[o.timeEnd] = 0
                }
                kpTrade[o.timeEnd] -= dir * o.uTrade
                // console.log('o.timeEnd', kpTrade[o.timeEnd])
                if (!haskey(kpCount, o.timeEnd)) {
                    kpCount[o.timeEnd] = 0
                }
                kpCount[o.timeEnd] -= 1 //平倉筆數-1
            }

        }

    })
    // console.log('kpTrade(未累計)', kpTrade)

    //計算累計持倉
    if (true) {

        //trades, 轉陣列與排序
        let trades = []
        each(kpTrade, (uTradeAll, time) => {
            if (!isNumber(uTradeAll)) {
                console.log('time', time)
                throw new Error(`invalid uTradeAll[${uTradeAll}]`)
            }
            trades.push({ time, uTradeAll })
        })
        trades = sortBy(trades, 'time')
        // console.log('trades', trades)

        //紀錄累計值
        let uAccm = 0
        let _trades = map(trades, (item) => {
            uAccm += item.uTradeAll
            return {
                ...item,
                uAccm,
            }
        })
        // console.log('_trades', _trades)

        //_kpTrade
        let _kpTrade = {}
        each(_trades, (v) => {
            _kpTrade[v.time] = v.uAccm
        })

        //update
        kpTrade = _kpTrade

    }
    // console.log('kpTrade(累計)', kpTrade)

    //計算累計持倉筆數(與kpTrade累計同款)
    if (true) {

        //counts, 轉陣列與排序
        let counts = []
        each(kpCount, (numTradeAll, time) => {
            if (!isNumber(numTradeAll)) {
                console.log('time', time)
                throw new Error(`invalid numTradeAll[${numTradeAll}]`)
            }
            counts.push({ time, numTradeAll })
        })
        counts = sortBy(counts, 'time')

        //紀錄累計值
        let nAccm = 0
        let _counts = map(counts, (item) => {
            nAccm += item.numTradeAll
            return {
                ...item,
                nAccm,
            }
        })

        //_kpCount
        let _kpCount = {}
        each(_counts, (v) => {
            _kpCount[v.time] = v.nAccm
        })

        //update
        kpCount = _kpCount

    }
    // console.log('kpCount(累計)', kpCount)

    //計算當前持倉金額與最大當前持倉金額
    ordersAll = map(ordersAll, (o) => {

        //當前持倉金額(USDT), 以下單時間計算
        let uTradeAll = get(kpTrade, o.timeStart, null)
        if (!isNumber(uTradeAll)) {
            throw new Error(`invalid uTradeAll`)
        }
        uTradeAll = Math.abs(uTradeAll) //做多或空皆會持有倉位, 負倉位(當前持倉金額)代表多空可抵銷, 故此處一律取絕對值顯示
        o.uTradeAll = uTradeAll

        //當前持倉金額佔比(%), 以下單時間計算
        let rTradeAll = dig(o.uTradeAll / uIni * 100, 2) + '%' //單位為%
        o.rTradeAll = rTradeAll

        //uTradeAllMax, rTradeAllMax
        if (uTradeAllMax < o.uTradeAll) {
            uTradeAllMax = o.uTradeAll
            rTradeAllMax = o.rTradeAll
        }

        //當前持倉筆數(同時持有的部位數), 以下單時間計算
        let numTradeAll = get(kpCount, o.timeStart, null)
        if (!isNumber(numTradeAll)) {
            throw new Error(`invalid numTradeAll`)
        }
        o.numTradeAll = numTradeAll

        //numTradeAllMax
        if (numTradeAllMax < o.numTradeAll) {
            numTradeAllMax = o.numTradeAll
        }

        return o
    })
    // console.log('ordersAll', ordersAll[size(ordersAll) - 1], size(ordersAll))

    //uEquityFinal, uCumuProfitOrLossFinal, rCumuProfitOrLossFinal
    if (true) {
        let ind = size(ordersFin) - 1
        uEquityFinal = get(ordersFin, `${ind}.uEquity`, '') //最終權益(USDT)
        uCumuProfitOrLossFinal = get(ordersFin, `${ind}.uCumuProfitOrLoss`, '') //最終盈虧(USDT)
        rCumuProfitOrLossFinal = get(ordersFin, `${ind}.rCumuProfitOrLoss`, '') //最終盈虧比例(%)
    }

    //rEquivalentDrawdown, 使用最大回撤時之最大盈虧, 扣掉初始資金, 再基於全部交易後才能得知的最大持倉, 換算出等效最大回撤比例
    if (true) {
        let v
        if ((uMaxMax - uIni + uTradeAllMax) > 0) {
            v = uDrawdownMax / (uMaxMax - uIni + uTradeAllMax)
        }
        else {
            v = 0
        }
        rEquivalentDrawdownMax = dig(v * 100, 2) + '%' //單位為%
        // console.log('uDrawdownMax', uDrawdownMax)
        // console.log('uMaxMax', uMaxMax)
        // console.log('uMaxMax - uIni', uMaxMax - uIni)
        // console.log('uMaxMax - uIni + uTradeAllMax', uMaxMax - uIni + uTradeAllMax)
    }

    //rEquivalentCumuProfitOrLossFinal
    if (uTradeAllMax > 0 && isNumber(uCumuProfitOrLossFinal)) {
        rEquivalentCumuProfitOrLossFinal = uCumuProfitOrLossFinal / uTradeAllMax
    }
    else {
        rEquivalentCumuProfitOrLossFinal = 0
    }
    rEquivalentCumuProfitOrLossFinal = dig((rEquivalentCumuProfitOrLossFinal) * 100, 2) + '%' //單位為%

    //rSharpe
    if (true) {

        let rAvgProfitOrLossDay = 0
        let rStdProfitOrLossDay = 0

        //rAvgProfitOrLossDay, 加權平均日盈虧比例(加權日均報酬), 把所有交易攤平成連續的日報酬
        let rrW = 0
        let rrD = 0
        each(ordersFin, (o) => {
            let rW = o.rProfitOrLossDay * o.dayHold
            rrW += rW
            let rD = o.dayHold
            rrD += rD
        })
        if (rrD > 0) {
            rAvgProfitOrLossDay = rrW / rrD
        }

        //rStdProfitOrLossDay, 加權日盈虧比例標準差(加權日標準差)
        let rtW = 0
        each(ordersFin, (o) => {
            let diff = o.rProfitOrLossDay - rAvgProfitOrLossDay
            let rW = o.dayHold * (diff * diff)
            rtW += rW
        })
        if (rrD > 1) {
            rStdProfitOrLossDay = Math.sqrt(rtW / (rrD - 1))
        }

        //rSharpe, 夏普值
        if (rStdProfitOrLossDay > 0) {
            rSharpe = (rAvgProfitOrLossDay / rStdProfitOrLossDay) * Math.sqrt(252)
        }
        if (rSharpe < -10) {
            rSharpe = -10
        }
        if (rSharpe > 10) {
            rSharpe = 10
        }

    }
    // console.log('rSharpe', rSharpe)

    let tStart = ott(timeOhlcStart)
    let tEnd = ott(timeOhlcEnd)

    //回測時長日
    let btDays = tEnd.diff(tStart, 'day')

    //回測時長年
    let btYears = btDays / 365

    //年化報酬率
    let rCumuProfitOrLossFinalNormYear = ''
    if (true) {
        let v
        if (uIni === 0 || btYears === 0) {
            v = 0
        }
        else {
            v = (uEquityFinal - uIni) / uIni / btYears
        }
        rCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
    }
    // let rCumuProfitOrLossFinalNormYear = `${dig((uEquityFinal - uIni) / uIni / btYears * 100, 2)}%`

    //等效年化報酬率
    let rEquivalentCumuProfitOrLossFinalNormYear = ''
    if (true) {
        let v
        if (uTradeAllMax === 0 || btYears === 0) {
            v = 0
        }
        else {
            v = (uEquityFinal - uIni) / uTradeAllMax / btYears
        }
        rEquivalentCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
    }
    // let rEquivalentCumuProfitOrLossFinalNormYear = `${dig((uEquityFinal - uIni) / uTradeAllMax / btYears * 100, 2)}%`

    //summary
    let summary = {
        btDays,
        btYears: dig(btYears, 1),
        numTrade,
        numTradeFin,
        numTradeUnsettled,
        uTradeAllMax,
        rTradeAllMax,
        numTradeAllMax,
        uDrawdownMax,
        rDrawdownMax,
        rEquivalentDrawdownMax,
        rSharpe,
        rWin,
        uEquityFinal,
        uCumuProfitOrLossFinal,
        rCumuProfitOrLossFinal,
        rCumuProfitOrLossFinalNormYear,
        rEquivalentCumuProfitOrLossFinal,
        rEquivalentCumuProfitOrLossFinalNormYear,
    }

    return summary
}


export default calcOrdersSummary