buildStrategyFastSession.mjs

import get from 'lodash-es/get.js'
import dig from 'wsemi/src/dig.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import genKLinesTpSl from './genKLinesTpSl.mjs'
import calcOrdersSummarySimple from './calcOrdersSummarySimple.mjs'
import settleOrderPnl from './settleOrderPnl.mjs'


/**
 * 建立策略快速評估session,供求解熱路徑(如RGA/PSO目標函數)重複評估同一K線窗之不同條件組合
 *
 * 建置期一次攤提runStrategy每次呼叫都要重建的不變量: 各因子{time,param}序列轉為K棒對位Float64Array(缺值填NaN)、各(tp,sl)組合之first-touch預算表(genKLinesTpSl,惰性建置+Map快取)
 * 評估期單迴圈逐棒判斷conds觸發並沿calcOrders同式結算(settleOrderPnl),摘要直接以calcOrdersSummarySimple計算,與官方全鏈(runStrategy→calcOrders→calcOrdersSummary)對應欄位嚴格相等
 * 觸發語義與runStrategy等價: 任一cond序列於該棒缺值(NaN)即整棒跳過(等同僅於全部cond序列皆有值之共同時間點判斷)、'and'組須全真且'or'組至少一真、sym為'>'比大於否則比小於
 * 序列param須為有限數值,缺值以該時間點不存在表示; opt.mode非'long'或'short'、arrOhlc或serieses無效時throw; evaluate之conds非有效陣列(含空陣列, 同runStrategy)或cond.key不在serieses時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 {Object} opt 輸入設定物件
 * @param {Array} opt.arrOhlc 輸入K線陣列,各元素需含time(秒時間字串)、Close、High、Low欄位,且依時間由小至大排序,與runStrategy之funGetSeries(keyOhlc)同一份
 * @param {String} opt.mode 輸入交易方向字串,'long'或'short'
 * @param {Object} opt.serieses 輸入因子序列物件,key為因子名稱,value為{time,param}陣列,param須為有限數值
 * @param {Array} [opt.tpsls=[]] 輸入預建first-touch表之{tp,sl}陣列,未列組合於evaluate時惰性建置,預設[]
 * @returns {Object} 回傳session物件,內含evaluate({conds,tp,sl,settings},opt)評估函數,conds各元素為{key,sym,th,opr}(與runStrategy同構),settings可給uIni(預設1000)、uTrade(預設1)、rFee(預設0.0005),evaluate預設回傳calcOrdersSummarySimple同構摘要物件,evaluate之opt.withOrders為true時回傳{orders,summary}(orders與loadKLinesTpSl之ordersFromBars同構,供除錯對照之慢速路徑)
 * @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 session = buildStrategyFastSession(ott, {
 *     arrOhlc,
 *     mode: 'long',
 *     serieses: { sig: arrSig },
 * })
 *
 * let sm = session.evaluate({
 *     conds: [{ key: 'sig', sym: '>', th: 0.5, opr: 'and' }],
 *     tp: 0.05,
 *     sl: 0.03,
 *     settings: { uIni: 1000, uTrade: 100, rFee: 0.0005 },
 * })
 * console.log(sm.numTrade, sm.rWin, sm.uEquityFinal)
 * // => 2 50.00% 1001.8
 *
 */
let buildStrategyFastSession = (ott, opt = {}) => {

    //mode
    let mode = get(opt, 'mode', '')
    if (mode !== 'long' && mode !== 'short') {
        throw new Error(`invalid opt.mode[${mode}] not 'long' or 'short'`)
    }

    //arrOhlc
    let arrOhlc = get(opt, 'arrOhlc', null)
    if (!isearr(arrOhlc)) {
        throw new Error(`invalid opt.arrOhlc`)
    }

    //serieses
    let serieses = get(opt, 'serieses', null)
    if (!iseobj(serieses)) {
        throw new Error(`invalid opt.serieses`)
    }

    //n, time, C
    let n = arrOhlc.length
    let time = new Array(n)
    let C = new Float64Array(n)
    let kpTimeInd = new Map() //K棒時間→index
    for (let i = 0; i < n; i++) {
        let r = arrOhlc[i]
        time[i] = r.time
        C[i] = r.Close
        kpTimeInd.set(r.time, i)
    }

    //timeOhlcStart, timeOhlcEnd
    let timeOhlcStart = n > 0 ? time[0] : ''
    let timeOhlcEnd = n > 0 ? time[n - 1] : ''

    //kpAligned, 各因子序列轉K棒對位Float64Array(缺值填NaN), 序列中不屬K棒之時間點不影響觸發(runStrategy亦僅於K棒時間判斷)
    let kpAligned = {}
    for (let key of Object.keys(serieses)) {
        let arr = new Float64Array(n).fill(NaN)
        for (let v of serieses[key]) {
            let ind = kpTimeInd.get(v.time)
            if (ind !== undefined) {
                arr[ind] = v.param
            }
        }
        kpAligned[key] = arr
    }

    //kpFirstTouch, 各(tp,sl)組合之first-touch表快取(惰性建置)
    let kpFirstTouch = new Map()
    let getFirstTouch = (tp, sl) => {
        let k = `${tp}_${sl}`
        let ft = kpFirstTouch.get(k)
        if (!ft) {
            ft = genKLinesTpSl(arrOhlc, { tp, sl, mode }) //tp/sl無效時由genKLinesTpSl throw
            kpFirstTouch.set(k, ft)
        }
        return ft
    }

    //預建
    let tpsls = get(opt, 'tpsls', [])
    if (isearr(tpsls)) {
        for (let v of tpsls) {
            getFirstTouch(v.tp, v.sl)
        }
    }

    //evaluate
    let evaluate = (input = {}, optEv = {}) => {

        //conds, 空陣列同runStrategy一律throw(isearr對空陣列回false)
        let conds = get(input, 'conds', [])
        if (!isearr(conds)) {
            throw new Error(`invalid conds`)
        }

        //tp, sl
        let tp = get(input, 'tp', null)
        let sl = get(input, 'sl', null)

        //settings
        let st = get(input, 'settings', {})
        let uIni = get(st, 'uIni', 1000)
        let uTrade = get(st, 'uTrade', 1)
        let rFee = get(st, 'rFee', 0.0005)
        let uFee = rFee * uTrade

        //withOrders
        let withOrders = get(optEv, 'withOrders', false)

        //ft
        let ft = getFirstTouch(tp, sl)
        let win = ft.win
        let hold = ft.hold

        //cs, 條件預解析(sym預設'>'、th預設0、opr非'and'/'or'一律'and', 與runStrategy同)
        let cs = conds.map((cond) => {
            let key = get(cond, 'key', '')
            let arr = kpAligned[key]
            if (!arr) {
                throw new Error(`invalid cond.key[${key}] not in opt.serieses`)
            }
            let sym = get(cond, 'sym', '>')
            let th = get(cond, 'th', 0)
            let opr = get(cond, 'opr', null)
            if (opr !== 'and' && opr !== 'or') {
                opr = 'and'
            }
            return { arr, gt: sym === '>', th, isOr: opr === 'or' }
        })
        //orders, 逐棒判斷觸發與結算(依timeStart升序產生, 累計順序與calcOrders一致)
        let orders = []
        let uEquity = uIni
        let uCumu = 0
        for (let i = 0; i < n; i++) {

            //bTrigger, 任一cond於該棒缺值(NaN)即整棒跳過(等同runStrategy僅於全部cond序列皆有值之共同時間點判斷)
            //  'and'組須全真(空組無約束), 'or'組至少一真(無or組不約束), 與runStrategy合成邏輯同構
            let bSkip = false
            let okAnd = true
            let hasOr = false
            let okOr = false
            for (let c of cs) {
                let y = c.arr[i]
                if (Number.isNaN(y)) {
                    bSkip = true
                    break
                }
                let b = c.gt ? y > c.th : y < c.th
                if (c.isOr) {
                    hasOr = true
                    okOr = okOr || b
                }
                else {
                    okAnd = okAnd && b
                }
            }
            if (bSkip || !okAnd || (hasOr && !okOr)) {
                continue
            }

            //進場, 該棒Close
            let c0 = C[i]
            let w = win[i]

            //o, 訂單紀錄(預設精簡欄位供calcOrdersSummarySimple使用; withOrders時與ordersFromBars同構)
            let o
            if (withOrders) {
                o = {
                    mode,
                    timeStart: time[i],
                    priceStart: c0,
                    uTrade,
                    rTakeProfit: tp,
                    priceTakeProfit: mode === 'long' ? (1 + tp) * c0 : (1 - tp) * c0,
                    rStopLoss: sl,
                    priceStopLoss: mode === 'long' ? (1 - sl) * c0 : (1 + sl) * c0,
                    timeEnd: '',
                    priceEnd: '',
                    modeResult: '',
                    rFee,
                    uFee,
                    uProfitOrLoss: '',
                    rProfitOrLoss: '',
                    uCumuProfitOrLoss: '',
                    rCumuProfitOrLoss: '',
                    uEquity: '',
                    uTradeAll: '',
                    rTradeAll: '',
                }
            }
            else {
                o = {
                    timeStart: time[i],
                    timeEnd: '',
                    uTrade,
                    uProfitOrLoss: '',
                    uEquity: '',
                    modeResult: '',
                }
            }

            //結算(win>=0為已觸發, 未觸發者留空=未平倉, 計入numTrade不計入已結績效)
            if (w >= 0) {
                let priceEnd
                if (w === 1) {
                    priceEnd = mode === 'long' ? (1 + tp) * c0 : (1 - tp) * c0 //止盈線
                }
                else {
                    priceEnd = mode === 'long' ? (1 - sl) * c0 : (1 + sl) * c0 //止損線
                }
                let u = settleOrderPnl(mode, uTrade, c0, priceEnd, uFee)
                uCumu += u
                uEquity += u
                o.timeEnd = time[i + hold[i]]
                o.uProfitOrLoss = u
                o.uEquity = uEquity
                o.modeResult = u < 0 ? 'loss' : 'profit'
                if (withOrders) {
                    o.priceEnd = priceEnd
                    o.rProfitOrLoss = u / uTrade
                    o.uCumuProfitOrLoss = uCumu
                    o.rCumuProfitOrLoss = dig((uCumu / uIni) * 100, 2) + '%'
                }
            }

            //push
            orders.push(o)

        }

        //summary, 直接調用官方calcOrdersSummarySimple(orders已依timeStart升序故skipSort)
        let summary = calcOrdersSummarySimple(ott, uIni, orders, timeOhlcStart, timeOhlcEnd, { skipSort: true })

        //withOrders時回傳訂單陣列供除錯對照
        if (withOrders) {
            return { orders, summary }
        }

        return summary
    }

    return {
        evaluate,
    }
}


export default buildStrategyFastSession