runStrategies.mjs

import flatten from 'lodash-es/flatten.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 isestr from 'wsemi/src/isestr.mjs'
import pmSeries from 'wsemi/src/pmSeries.mjs'
import runStrategy from './runStrategy.mjs'
import calcSummary from './calcSummary.mjs'


/**
 * 執行多策略回測
 *
 * 逐策略呼叫runStrategy(withSummary:false),各單附加策略sid後合併並依timeStart排序
 * 以各策略settings.uIni總和為初始資金呼叫calcSummary統計合併摘要
 * 個別策略執行失敗(如時間點無參數無法下單)時強制略過
 *
 * 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 {Array} strategies 輸入策略陣列,各元素需含sid與runStrategy之strategy欄位
 * @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 settings = { uIni: 1000, uTrade: 100, rTakeProfit: 0.05, rStopLoss: 0.03, rFee: 0.0005 }
 * let strategies = [
 *     { sid: 's1', mode: 'long', keyOhlc: 'btc', conds: [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }], settings },
 *     { sid: 's2', mode: 'short', keyOhlc: 'btc', conds: [{ key: 'sig', sym: '<', th: 0.5, opr: 'and' }], settings },
 * ]
 *
 * let rr = await runStrategies(ott, strategies, funGetSeries)
 * console.log(rr.orders.map((o) => `${o.sid} ${o.timeStart} ${o.mode} ${o.modeResult || 'unsettled'}`))
 * // => [
 * //   's1 2020-01-01T00:00:00 long profit',
 * //   's2 2020-01-01T04:00:00 short profit',
 * //   's1 2020-01-01T08:00:00 long loss',
 * //   's2 2020-01-01T12:00:00 short loss',
 * //   's2 2020-01-01T16:00:00 short loss',
 * //   's2 2020-01-01T20:00:00 short unsettled'
 * // ]
 * console.log(rr.summary.uIni, rr.summary.numTrade, rr.summary.rWin)
 * // => 2000 6 40.00%
 *
 */
let runStrategies = async (ott, strategies, funGetSeries, opt = {}) => {

    //withCalcOrderProfitOrLoss
    let withCalcOrderProfitOrLoss = get(opt, 'withCalcOrderProfitOrLoss', true)

    //withSummary
    let withSummary = get(opt, 'withSummary', true)

    //uIniAll, ordersAll
    let uIniAll = 0
    let ordersAll = []
    let r = null
    await pmSeries(strategies, async(strategy, k) => {

        //有些策略因為時間點無參數故無法計算, 代表無法下單, 故強制略過
        try {

            //runStrategy
            r = await runStrategy(ott, strategy, funGetSeries, { withCalcOrderProfitOrLoss, withSummary: false })

            //uIni
            let uIni = get(strategy, 'settings.uIni', '')
            if (!isNumber(uIni)) {
                throw new Error(`uIni[${uIni}] is not a number`)
            }
            // console.log('      ', 'uIni =', uIni, 'numTrade =', strategy.summary.numTrade)

            //累加初始金額(本金)
            uIniAll += uIni

            //add sid, push
            if (size(r.orders) >= 1) {

                //各單額外儲存策略sid
                r.orders = map(r.orders, (o) => {
                    o.sid = strategy.sid
                    return o
                })

                //push
                ordersAll.push(r.orders)

            }

        }
        catch (err) {
            // console.log(err)
        }

    })
    ordersAll = flatten(ordersAll)
    // console.log('ordersAll', ordersAll)

    //sortBy
    if (size(ordersAll) >= 2) {
        ordersAll = sortBy(ordersAll, 'timeStart')
    }
    // console.log('size(ordersAll)', size(ordersAll))

    //timeOhlcStart, timeOhlcEnd
    let timeOhlcStart = get(r, 'summary.timeOhlcStart', '') //各策略ohlc起訖時間皆相同, 取最後r內的timeOhlcStart來使用
    let timeOhlcEnd = get(r, 'summary.timeOhlcEnd', '') //各策略ohlc起訖時間皆相同, 取最後r內的timeOhlcEnd來使用

    //check
    if (!isestr(timeOhlcStart)) {
        throw new Error(`invalid timeOhlcStart`)
    }
    if (!isestr(timeOhlcEnd)) {
        throw new Error(`invalid timeOhlcEnd`)
    }

    //summary
    let summary = {
        timeTest: ott().format('YYYY-MM-DDTHH:mm:ssZ'),
        timeOhlcStart,
        timeOhlcEnd,
    }

    //withSummary
    if (withSummary) {
        summary = await calcSummary(ott, uIniAll, ordersAll, timeOhlcStart, timeOhlcEnd)
    }

    //rr
    let rr = {
        orders: ordersAll,
        summary,
    }

    return rr
}

export default runStrategies