mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
190 lines
7.3 KiB
TypeScript
190 lines
7.3 KiB
TypeScript
/**
|
||
* 从组合净值曲线(EquityPoint[])重算绩效指标。
|
||
*
|
||
* 背景:PortfolioResult.total_performance 只有 4 个字段(total_return/annual_return/
|
||
* total_stocks/total_cash),不够评级。但 combined_equity 提供了完整的组合净值序列,
|
||
* 可以在前端重算夏普/索提诺/卡玛/回撤/波动率/回撤持续天数。
|
||
*
|
||
* 注意:净值序列算不出胜率/利润因子/交易数(这些是逐笔成交统计),所以组合评级
|
||
* 不用这两个维度,改用风险调整收益 + 索提诺补位。
|
||
*
|
||
* 频率假设:combined_equity 的每个点对应一个交易日(日线),
|
||
* 夏普/波动率按 √252 年化。如果是周线/月线,年化因子需要调整。
|
||
*/
|
||
|
||
import type { EquityPoint } from '../types.ts'
|
||
|
||
/** 年化因子(按交易日)。 */
|
||
const TRADING_DAYS_PER_YEAR = 252
|
||
|
||
/** 重算后的组合级指标(仅包含净值可推导的字段)。 */
|
||
export interface CombinedMetrics {
|
||
/** 总收益率(小数,1.2643 = +126.43%) */
|
||
total_return: number
|
||
/** 年化收益率(小数)。按 (1+total)^(年数) - 1 反推。 */
|
||
annual_return: number
|
||
/** 最大回撤(小数,0.4165 = 41.65%) */
|
||
max_drawdown: number
|
||
/** 回撤持续天数(峰值到恢复的最长交易日数,未恢复则到末日) */
|
||
max_dd_duration: number
|
||
/** 夏普比率(年化,无风险利率按 0 处理) */
|
||
sharpe: number
|
||
/** 索提诺比率(年化,仅用下行波动) */
|
||
sortino: number
|
||
/** 卡玛比率 = 年化收益 / 最大回撤 */
|
||
calmar: number
|
||
/** 波动率(年化,小数) */
|
||
volatility: number
|
||
/** 净值点数 */
|
||
n_points: number
|
||
/** 跨度(年数,用于年化) */
|
||
years: number
|
||
}
|
||
|
||
/**
|
||
* 从净值序列重算组合级绩效指标。
|
||
*
|
||
* @param equity 净值序列,按时间升序。至少需要 2 个点才有统计意义。
|
||
* @returns 重算结果;如果数据不足,相关字段返回 0(调用方应结合 n_points 判断)。
|
||
*/
|
||
export function computeCombinedMetrics(equity: EquityPoint[]): CombinedMetrics {
|
||
const n = equity.length
|
||
|
||
// 兜底:数据极少时返回全 0,避免除零或 NaN 污染
|
||
if (n < 2) {
|
||
return {
|
||
total_return: 0,
|
||
annual_return: 0,
|
||
max_drawdown: 0,
|
||
max_dd_duration: 0,
|
||
sharpe: 0,
|
||
sortino: 0,
|
||
calmar: 0,
|
||
volatility: 0,
|
||
n_points: n,
|
||
years: 0,
|
||
}
|
||
}
|
||
|
||
// 取每个点的 total(= cash + position_value),与 EquityChart 口径一致
|
||
const totals = equity.map((e) => e.total)
|
||
const startValue = totals[0]
|
||
const endValue = totals[n - 1]
|
||
|
||
// ── 总收益 & 年化 ────────────────────────────────────────────────────────
|
||
const total_return = startValue > 0 ? endValue / startValue - 1 : 0
|
||
// 跨度(年):按交易日数 / 252。若无日期信息,退化为 n / 252。
|
||
const firstDt = Date.parse(equity[0].datetime)
|
||
const lastDt = Date.parse(equity[n - 1].datetime)
|
||
const years =
|
||
Number.isFinite(firstDt) && Number.isFinite(lastDt) && lastDt > firstDt
|
||
? (lastDt - firstDt) / (365.25 * 24 * 3600 * 1000)
|
||
: n / TRADING_DAYS_PER_YEAR
|
||
const annual_return = years > 0 && endValue > 0 && startValue > 0
|
||
? Math.pow(endValue / startValue, 1 / years) - 1
|
||
: 0
|
||
|
||
// ── 逐期收益率(用于夏普/波动率) ────────────────────────────────────────
|
||
const periodReturns: number[] = []
|
||
for (let i = 1; i < n; i++) {
|
||
if (totals[i - 1] > 0) {
|
||
periodReturns.push(totals[i] / totals[i - 1] - 1)
|
||
}
|
||
}
|
||
|
||
const meanPeriod = mean(periodReturns)
|
||
const stdPeriod = stddev(periodReturns, meanPeriod)
|
||
|
||
// 年化波动率 = 日波动率 × √252
|
||
const volatility = stdPeriod * Math.sqrt(TRADING_DAYS_PER_YEAR)
|
||
// 夏普 = 年化超额收益 / 年化波动(无风险利率按 0)
|
||
// 等价于 meanPeriod / stdPeriod × √252
|
||
const sharpe = stdPeriod > 0 ? (meanPeriod / stdPeriod) * Math.sqrt(TRADING_DAYS_PER_YEAR) : 0
|
||
|
||
// ── 索提诺(仅用下行波动) ───────────────────────────────────────────────
|
||
const downsideReturns = periodReturns.filter((r) => r < 0)
|
||
const downsideStd = downsideReturns.length > 0
|
||
? Math.sqrt(downsideReturns.reduce((s, r) => s + r * r, 0) / downsideReturns.length)
|
||
: 0
|
||
const sortino = downsideStd > 0
|
||
? (meanPeriod / downsideStd) * Math.sqrt(TRADING_DAYS_PER_YEAR)
|
||
: 0
|
||
|
||
// ── 最大回撤 & 持续天数 ─────────────────────────────────────────────────
|
||
// 优先用后端已算好的 drawdown_pct(与图表一致),反推峰值&持续更准。
|
||
// 若后端字段缺失,再退回从 totals 反推。
|
||
// 回撤持续 = 最长水下期:从峰值跌落到重新创新高的最长交易日数
|
||
// (末日仍未修复则计到最后一天),与 glossary / grading 锚点同口径。
|
||
let maxDrawdown = 0
|
||
let maxDdDuration = 0
|
||
|
||
if (equity[0].drawdown_pct !== undefined) {
|
||
let lastPeakIdx = 0
|
||
for (let i = 0; i < n; i++) {
|
||
const dd = equity[i].drawdown_pct
|
||
if (dd > maxDrawdown) {
|
||
maxDrawdown = dd
|
||
}
|
||
// 触及前高(drawdown_pct == 0)结算一段水下期;相邻新高不算回撤
|
||
if (dd === 0) {
|
||
if (i - lastPeakIdx > 1) {
|
||
maxDdDuration = Math.max(maxDdDuration, i - lastPeakIdx)
|
||
}
|
||
lastPeakIdx = i
|
||
}
|
||
}
|
||
// 末日仍在水下:从最后一次创新高计到最后一天
|
||
maxDdDuration = Math.max(maxDdDuration, n - 1 - lastPeakIdx)
|
||
} else {
|
||
// 退化路径:从 totals 反推
|
||
let runningPeak = totals[0]
|
||
let lastPeakIdx = 0
|
||
for (let i = 0; i < n; i++) {
|
||
if (totals[i] > runningPeak) {
|
||
runningPeak = totals[i]
|
||
}
|
||
const ddPct = runningPeak > 0 ? (runningPeak - totals[i]) / runningPeak : 0
|
||
if (ddPct > maxDrawdown) {
|
||
maxDrawdown = ddPct
|
||
}
|
||
if (ddPct === 0) {
|
||
if (i - lastPeakIdx > 1) {
|
||
maxDdDuration = Math.max(maxDdDuration, i - lastPeakIdx)
|
||
}
|
||
lastPeakIdx = i
|
||
}
|
||
}
|
||
maxDdDuration = Math.max(maxDdDuration, n - 1 - lastPeakIdx)
|
||
}
|
||
|
||
// ── 卡玛比率 = 年化收益 / 最大回撤 ───────────────────────────────────────
|
||
const calmar = maxDrawdown > 0 ? annual_return / maxDrawdown : (annual_return > 0 ? Infinity : 0)
|
||
|
||
return {
|
||
total_return,
|
||
annual_return,
|
||
max_drawdown: maxDrawdown,
|
||
max_dd_duration: maxDdDuration,
|
||
sharpe,
|
||
sortino,
|
||
// 卡玛无穷大时(无回撤)封顶为一个大数,避免评级引擎 NaN
|
||
calmar: Number.isFinite(calmar) ? calmar : 999,
|
||
volatility,
|
||
n_points: n,
|
||
years,
|
||
}
|
||
}
|
||
|
||
/** 求均值。空数组返回 0。 */
|
||
function mean(xs: number[]): number {
|
||
if (xs.length === 0) return 0
|
||
return xs.reduce((s, x) => s + x, 0) / xs.length
|
||
}
|
||
|
||
/** 求样本标准差(n-1 分母)。空/单点返回 0。 */
|
||
function stddev(xs: number[], m: number): number {
|
||
if (xs.length < 2) return 0
|
||
const sumSq = xs.reduce((s, x) => s + (x - m) * (x - m), 0)
|
||
return Math.sqrt(sumSq / (xs.length - 1))
|
||
}
|