Skip to content

Commit 8d716a6

Browse files
committed
耗尽预测改为实时速率:近3小时实际消耗优先 (v1.12.1)
旧算法对 48h 窗口做等权最小二乘,速率突变时被安静时段稀释—— 合成回测(40h 慢烧后提速到 $48/天)显示旧算法只估出 $5.4/天, ETA 偏差 10 倍;近窗实际速率精确命中,真实数据上两者误差相当。 - predict 分层估计:近 3 小时实际消耗速率(余额下降求和÷跨度, 充值上升天然忽略)→ 不足退近 12 小时 → 冷启动兜底原回归 - 返回 basis 口径字段,卡片与弹窗显示「≈¥X/天(近3小时)」 - ETA 告警走同一函数,同步获得实时性 - 真实数据验证:zz1cc 近 3 小时实际 $747/天 → ETA 2.3 天 (旧回归被夜间安静时段稀释,完全暴露不出该风险)
1 parent f9cb70f commit 8d716a6

3 files changed

Lines changed: 63 additions & 40 deletions

File tree

lib/history.js

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -96,56 +96,79 @@ export class History {
9696
return arr.filter((p) => p[0] >= cutoff);
9797
}
9898

99+
// 某窗口内的实际消耗速率($/天):余额下降求和 ÷ 实际跨度。
100+
// 上升视为充值自动忽略;跨度不足 minSpanH 或点太少返回 null
101+
burnRate(stationId, windowHours, minSpanH = 1, minPoints = 5) {
102+
const arr = this.data[stationId] || [];
103+
const cutoff = Date.now() - windowHours * 3600 * 1000;
104+
const seg = arr.filter((p) => p[0] >= cutoff);
105+
if (seg.length < minPoints) return null;
106+
const spanH = (seg[seg.length - 1][0] - seg[0][0]) / 3600000;
107+
if (spanH < minSpanH) return null;
108+
let drop = 0;
109+
for (let i = 1; i < seg.length; i++) {
110+
const d = seg[i - 1][1] - seg[i][1];
111+
if (d > 0) drop += d;
112+
}
113+
return { burnPerDay: (drop / spanH) * 24, samples: seg.length, spanHours: spanH };
114+
}
115+
99116
/**
100-
* 耗尽预测。
101-
* 只回归最近一次「充值」之后的数据段(余额显著上升视为充值),
102-
* 窗口默认取最近 48 小时,至少需要 3 个点且时间跨度 ≥ 10 分钟。
103-
* 返回 { burnPerDay, etaDays, etaAt, samples, spanHours } 或 null(数据不足/无消耗)。
117+
* 耗尽预测——按「实时速率」分层估计:
118+
* 1. 近 3 小时实际消耗速率(提速/降速 3 小时内即反映到 ETA)
119+
* 2. 数据不足退近 12 小时
120+
* 3. 再不足退回充值截断后的 48 小时最小二乘回归(冷启动兜底)
121+
* 回测依据:合成提速场景下旧 48h 等权回归把 ¥48/天 稀释成 ¥5.4/天
122+
*(ETA 偏差 10 倍),近窗实际速率精确命中;真实数据上两者误差相当。
123+
* 返回 { burnPerDay, etaDays, etaAt, basis, samples, spanHours } 或 null。
104124
*/
105125
predict(stationId, windowHours = 48) {
106126
const all = this.data[stationId] || [];
107-
const cutoff = Date.now() - windowHours * 3600 * 1000;
108-
let pts = all.filter((p) => p[0] >= cutoff);
109-
if (pts.length < 3) pts = all.slice(-50); // 窗口内不足则退回最近 50 个点
110-
if (pts.length < 3) return null;
127+
if (all.length < 3) return null;
128+
const latest = all[all.length - 1];
111129

112-
// 截断到最近一次充值之后
113-
let start = 0;
114-
for (let i = 1; i < pts.length; i++) {
115-
if (pts[i][1] > pts[i - 1][1] + TOPUP_EPSILON) start = i;
130+
let est = this.burnRate(stationId, 3, 1, 5);
131+
let basis = "近3小时";
132+
if (!est) { est = this.burnRate(stationId, 12, 2, 5); basis = "近12小时"; }
133+
if (!est) {
134+
// 冷启动兜底:充值截断后的窗口回归
135+
const cutoff = Date.now() - windowHours * 3600 * 1000;
136+
let pts = all.filter((p) => p[0] >= cutoff);
137+
if (pts.length < 3) pts = all.slice(-50);
138+
let start = 0;
139+
for (let i = 1; i < pts.length; i++) {
140+
if (pts[i][1] > pts[i - 1][1] + TOPUP_EPSILON) start = i;
141+
}
142+
pts = pts.slice(start);
143+
if (pts.length < 3) return null;
144+
const spanMs = pts[pts.length - 1][0] - pts[0][0];
145+
if (spanMs < 10 * 60 * 1000) return null;
146+
const t0 = pts[0][0];
147+
let sx = 0, sy = 0, sxx = 0, sxy = 0;
148+
const n = pts.length;
149+
for (const [t, r] of pts) {
150+
const x = (t - t0) / 3600000;
151+
sx += x; sy += r; sxx += x * x; sxy += x * r;
152+
}
153+
const denom = n * sxx - sx * sx;
154+
if (Math.abs(denom) < 1e-9) return null;
155+
const slope = (n * sxy - sx * sy) / denom;
156+
est = { burnPerDay: slope < 0 ? -slope * 24 : 0, samples: n, spanHours: spanMs / 3600000 };
157+
basis = "回归";
116158
}
117-
pts = pts.slice(start);
118-
if (pts.length < 3) return null;
119-
120-
const spanMs = pts[pts.length - 1][0] - pts[0][0];
121-
if (spanMs < 10 * 60 * 1000) return null; // 跨度太短,预测无意义
122-
123-
// 最小二乘:remaining = a + b * t
124-
const t0 = pts[0][0];
125-
let sx = 0, sy = 0, sxx = 0, sxy = 0;
126-
const n = pts.length;
127-
for (const [t, r] of pts) {
128-
const x = (t - t0) / 3600000; // 小时
129-
sx += x; sy += r; sxx += x * x; sxy += x * r;
130-
}
131-
const denom = n * sxx - sx * sx;
132-
if (Math.abs(denom) < 1e-9) return null;
133-
const slope = (n * sxy - sx * sy) / denom; // $/小时
134-
135-
const burnPerDay = slope < 0 ? -slope * 24 : 0;
136-
const latest = pts[pts.length - 1];
137-
const remaining = latest[1];
138159

160+
const burnPerDay = est.burnPerDay;
139161
if (burnPerDay < 0.0001) {
140-
return { burnPerDay: 0, etaDays: null, etaAt: null, samples: n, spanHours: spanMs / 3600000 };
162+
return { burnPerDay: 0, etaDays: null, etaAt: null, basis, samples: est.samples, spanHours: Math.round(est.spanHours * 10) / 10 };
141163
}
142-
const etaDays = remaining / burnPerDay;
164+
const etaDays = latest[1] / burnPerDay;
143165
return {
144166
burnPerDay: Math.round(burnPerDay * 100) / 100,
145167
etaDays: Math.round(etaDays * 10) / 10,
146168
etaAt: new Date(latest[0] + etaDays * 86400000).toISOString(),
147-
samples: n,
148-
spanHours: Math.round((spanMs / 3600000) * 10) / 10,
169+
basis,
170+
samples: est.samples,
171+
spanHours: Math.round(est.spanHours * 10) / 10,
149172
};
150173
}
151174
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.12.0",
3+
"version": "1.12.1",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ function fmtEtaText(days) {
130130
}
131131
function etaText(p, rate = 1) {
132132
if (!p) return null;
133-
if (p.burnPerDay === 0) return { text: "近期无消耗", cls: "" };
133+
if (p.burnPerDay === 0) return { text: `${p.basis || "近期"}无消耗`, cls: "" };
134134
if (p.etaDays == null) return null;
135135
const cls = p.etaDays <= (state.rules.etaDays ?? 3) ? "danger" : p.etaDays <= 7 ? "warn" : "";
136-
return { text: `≈ ${cny(p.burnPerDay * rate)}/天 · 预计 ${fmtEtaText(p.etaDays)}后耗尽`, cls };
136+
return { text: `≈ ${cny(p.burnPerDay * rate)}/天${p.basis || "估算"}· 预计 ${fmtEtaText(p.etaDays)}后耗尽`, cls };
137137
}
138138

139139
// ---- 登录 -------------------------------------------------------------------

0 commit comments

Comments
 (0)