//精簡版 calcOrdersSummary — 只算「選股熱路徑」需要的欄位, 純迴圈無 lodash、不需先跑 calcOrdersRatio。
//
//用途: walk-forward 選股每組合每日各算一次摘要(數百萬次), 原 calcOrdersRatio+calcOrdersSummary 走 lodash 多趟、配大量物件, 太慢。
// 本版直接從 orders 一趟算出排序所需的 {numTrade, rWin, 等效年化}(+ 幾個便宜欄), 不算 drawdown/Sharpe/累計曲線。
//
//輸入 ordersAll: 已含 timeStart/timeEnd/uTrade/uProfitOrLoss/uEquity/modeResult 的訂單(由 calcOrders/calcOrdersFast/ordersFromBars 設定)。
// 與原 calcOrdersSummary 的對應欄位 bit-exact:
// numTrade / numTradeFin / rWin / uTradeAllMax / rTradeAllMax / uEquityFinal / btDays / btYears /
// rEquivalentCumuProfitOrLossFinalNormYear
// (演算法與原版一致: 最大同時持倉 = 各單 [timeStart,timeEnd] 佔 uTrade 的最大同時總和, 以「各訂單 timeStart 時刻累計持倉」取 max;
// 等效年化 = (uEquityFinal - uIni) / uTradeAllMax / 年數。)
import dig from 'wsemi/src/dig.mjs'
import isestr from 'wsemi/src/isestr.mjs'
/**
* 統計全部訂單之精簡版回測摘要
*
* 為calcOrdersSummary之精簡版,只算選股熱路徑所需欄位,純迴圈實作且不需先跑calcOrdersRatio
* 對應欄位與完整版bit-exact: numTrade、numTradeFin、rWin、uTradeAllMax、rTradeAllMax、uEquityFinal、btDays、btYears、rEquivalentCumuProfitOrLossFinalNormYear
* 不算drawdown、夏普值與累計曲線
*
* 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 輸入已結算訂單陣列(經calcOrders設定timeStart、timeEnd、uTrade、uEquity、modeResult)
* @param {String} timeOhlcStart 輸入回測起始秒時間字串
* @param {String} timeOhlcEnd 輸入回測結束秒時間字串
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Boolean} [opt.skipSort=false] 輸入呼叫端保證ordersAll已依timeStart升序時可跳過排序布林值,預設false
* @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 })
* let summary = calcOrdersSummarySimple(ott, 1000, orders, '2020-01-01T00:00:00', '2020-01-01T20:00:00')
* console.log(summary)
* // => {
* // numTrade: 4,
* // numTradeFin: 3,
* // rWin: '66.67%',
* // uTradeAllMax: 200,
* // rTradeAllMax: '20.00%',
* // uEquityFinal: 1006.6999999999999,
* // btDays: 0,
* // btYears: '0.0',
* // rEquivalentCumuProfitOrLossFinalNormYear: '0.00%',
* // timeOhlcStart: '2020-01-01T00:00:00',
* // timeOhlcEnd: '2020-01-01T20:00:00'
* // }
*
*/
let calcOrdersSummarySimple = (ott, uIni, ordersAll, timeOhlcStart, timeOhlcEnd, opt = {}) => {
//依 timeStart 排序(與原版一致; uEquity 是 calcOrdersFast 依此序的累計)。
// opt.skipSort: 呼叫端保證 ordersAll 已依 timeStart 升序時可跳過 slice+sort(熱路徑省數千筆排序); 預設 false 維持原行為。
if (!(opt && opt.skipSort)) {
ordersAll = ordersAll.slice().sort((a, b) => a.timeStart < b.timeStart ? -1 : (a.timeStart > b.timeStart ? 1 : 0))
}
let numTrade = ordersAll.length
let numTradeFin = 0
let nWin = 0
let uEquityFinal = '' //無已平倉時 '' (對齊原版 _.get 預設)
//持倉變化事件: 下單 +uTrade, 平倉 -uTrade (long/short 皆 +1, 都佔用倉位)
let delta = new Map()
for (let o of ordersAll) {
if (isestr(o.modeResult)) {
numTradeFin++
if (o.modeResult === 'profit') nWin++
uEquityFinal = o.uEquity //依 timeStart 序, 最後一筆已平倉者勝出 = 原版 ordersFin[last].uEquity
}
delta.set(o.timeStart, (delta.get(o.timeStart) || 0) + o.uTrade)
if (isestr(o.timeEnd)) delta.set(o.timeEnd, (delta.get(o.timeEnd) || 0) - o.uTrade)
}
//累計持倉(各時刻) → 各訂單 timeStart 時刻取 max abs = 最大同時持倉
let times = [...delta.keys()].sort()
let cum = 0
let cumAt = new Map()
for (let t of times) {
cum += delta.get(t); cumAt.set(t, cum)
}
let uTradeAllMax = 0
let rTradeAllMax = '' //無訂單(峰值從未更新)時維持'' (對齊原版初始值語義, 原版僅於uTradeAllMax被更新時賦值)
for (let o of ordersAll) {
let v = Math.abs(cumAt.get(o.timeStart))
if (v > uTradeAllMax) {
uTradeAllMax = v
rTradeAllMax = dig(uTradeAllMax / uIni * 100, 2) + '%'
}
}
let rWin = dig(nWin / numTradeFin * 100, 2) + '%'
let btDays = ott(timeOhlcEnd).diff(ott(timeOhlcStart), 'day')
let btYears = btDays / 365
let rEquivalentCumuProfitOrLossFinalNormYear
{
let v = (uTradeAllMax === 0 || btYears === 0) ? 0 : (uEquityFinal - uIni) / uTradeAllMax / btYears
rEquivalentCumuProfitOrLossFinalNormYear = `${dig(v * 100, 2)}%`
}
return {
numTrade,
numTradeFin,
rWin,
uTradeAllMax,
rTradeAllMax,
uEquityFinal,
btDays,
btYears: dig(btYears, 1),
rEquivalentCumuProfitOrLossFinalNormYear,
timeOhlcStart, //回傳區間起訖(echo 傳入參數, 對齊完整版 calcSummary:53-54; deterministic, 不破壞 bit-exact)
timeOhlcEnd,
}
}
export default calcOrdersSummarySimple