Skip to content

Commit ccaf356

Browse files
committed
小时级预测:小时画像 × 近期水平,含今日全天估算 (v1.9.0)
- new-api 数据看板本身是小时桶,聚合近 14 天小时序列(缺时补 0) - forecastHourly:每个钟点取近 7 天中位数构成日内画像,按近 48 小时 水平缩放。真实数据回测(每 6 小时滚动预测未来 24h): 24h 总量误差 38%,优于日级方法的 46%(昼夜规律提供结构, 用户站点峰谷比高达 1700 倍);持续性 53%、混合法 44% 均更差 - 逐时置信带取该钟点历史 20%~80% 分位 × 水平缩放 - 「未来 24 小时预测」图表:过去 24h 实心柱 + 「现在」分界线 + 未来 24h 浅色柱,悬停显示预测与区间;副标题展示 今天已消费 / 全天预计(画像剩余小时累加)/ 未来 24h 合计 / 回测偏差 - 右侧目录新增「小时预测」项
1 parent 3039101 commit ccaf356

5 files changed

Lines changed: 159 additions & 2 deletions

File tree

lib/forecast.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,70 @@ const METHOD_LABEL = {
118118
"ewma-dow": "均线 + 星期因子",
119119
};
120120

121+
/**
122+
* 小时级预测:小时画像(每个钟点的中位消费)× 近期水平缩放。
123+
* 回测选型结论:昼夜规律强的数据上,24 小时总量误差比日级方法更低
124+
*(真实数据 38% vs 日级 46%);持续性/混合法都更差。
125+
*
126+
* @param points [{t: 整点 ms, cost}] 升序、缺时补 0、不含当前未完小时
127+
* @param hodOf (ms) => 0-23,调用方提供时区感知的“当地钟点”函数
128+
* @param horizon 预测小时数
129+
* @returns { points:[{t,cost,lo,hi}], next24Total, backtestWapePct } 或 null
130+
*/
131+
export function forecastHourly(points, hodOf, horizon = 24) {
132+
const n = points.length;
133+
if (n < 72) return null; // 至少 3 天小时数据
134+
135+
const fit = (vals, ts) => {
136+
// 画像:近 7 天每个钟点的中位数与分位(不足 7 天用全部)
137+
const cut = ts[ts.length - 1] - 7 * 86400000;
138+
const byH = Array(24).fill(0).map(() => []);
139+
for (let i = 0; i < vals.length; i++) if (ts[i] >= cut) byH[hodOf(ts[i])].push(vals[i]);
140+
const prof = byH.map((a) => median(a));
141+
const profSum = prof.reduce((a, b) => a + b, 0);
142+
// 水平:近 48 小时均值折算成日总量
143+
const lvCut = ts[ts.length - 1] - 48 * 3600000;
144+
let recent = 0, hours = 0;
145+
for (let i = 0; i < vals.length; i++) if (ts[i] >= lvCut) { recent += vals[i]; hours++; }
146+
const dailyLevel = hours > 0 ? (recent / hours) * 24 : profSum;
147+
const scale = profSum > 1e-9 ? dailyLevel / profSum : 0;
148+
return { prof, byH, scale };
149+
};
150+
151+
const vals = points.map((p) => p.cost);
152+
const ts = points.map((p) => p.t);
153+
const { prof, byH, scale } = fit(vals, ts);
154+
155+
const lastT = ts[n - 1];
156+
const out = [];
157+
for (let k = 1; k <= horizon; k++) {
158+
const t = lastT + k * 3600000;
159+
const h = hodOf(t);
160+
const p = Math.max(0, prof[h] * scale);
161+
const qlo = quantile(byH[h].length ? byH[h] : [0], 0.2) * scale;
162+
const qhi = quantile(byH[h].length ? byH[h] : [0], 0.8) * scale;
163+
out.push({ t, cost: r2(p), lo: r2(Math.min(qlo, p)), hi: r2(Math.max(qhi, p * 1.2)) });
164+
}
165+
166+
// 内部回测:每 6 小时一个测试点,评估未来 24 小时总量误差
167+
let esum = 0, asum = 0;
168+
for (let i = Math.max(72, n - 7 * 24); i + 24 <= n; i += 6) {
169+
const f = fit(vals.slice(0, i), ts.slice(0, i));
170+
let ps = 0, as = 0;
171+
for (let k = 0; k < 24; k++) {
172+
ps += Math.max(0, f.prof[hodOf(ts[i] + (k + 1) * 3600000)] * f.scale);
173+
as += vals[i + k];
174+
}
175+
esum += Math.abs(ps - as); asum += as;
176+
}
177+
178+
return {
179+
points: out,
180+
next24Total: r2(out.slice(0, 24).reduce((a, p) => a + p.cost, 0)),
181+
backtestWapePct: asum > 0 ? Math.round((esum / asum) * 100) : null,
182+
};
183+
}
184+
121185
/**
122186
* @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、缺日补 0、不含今天
123187
* @param horizon 预测天数

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.8.1",
3+
"version": "1.9.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,15 @@ function renderOwnBody() {
959959
<div class="chart-wrap" id="ownModelChart"></div>
960960
</div>
961961
</div>
962+
${d.hourly ? `
963+
<div class="charts-grid" style="grid-template-columns:1fr" data-toc="小时预测">
964+
<div class="panel chart-card">
965+
<div class="chart-card-head"><div><h3>未来 24 小时预测</h3><div class="chart-sub">今天已消费 ${cny(d.hourly.todaySoFar * rate)} · 全天预计 ≈${cny(d.hourly.todayEst * rate)} · 未来 24h 合计 ≈${cny(d.hourly.next24Total * rate)}${
966+
d.hourly.backtestWapePct != null ? ` · 24h 总量回测偏差 ±${d.hourly.backtestWapePct}%` : ""
967+
}</div></div></div>
968+
<div class="chart-wrap" id="ownHourlyChart"></div>
969+
</div>
970+
</div>` : ""}
962971
<div class="charts-grid" data-toc="消费预测">
963972
<div class="panel chart-card">
964973
<div class="chart-card-head"><div><h3>消费预测</h3><div class="chart-sub">${esc(fcSub)}</div></div></div>
@@ -1000,11 +1009,62 @@ function renderOwnBody() {
10001009
drawUsageTrend($("#ownTrendChart"), buckets);
10011010
drawUsageModels($("#ownModelChart"), models);
10021011
drawOwnUsers($("#ownUserChart"), users);
1012+
if (d.hourly) {
1013+
drawHourlyChart($("#ownHourlyChart"),
1014+
d.hourly.past.map((p) => ({ ...p, cost: p.cost * rate })),
1015+
d.hourly.next.map((p) => ({ ...p, cost: p.cost * rate, lo: p.lo * rate, hi: p.hi * rate })));
1016+
}
10031017
drawForecast($("#ownForecastChart"), d.daily.map((x) => ({ t: x.t, cost: x.cost * rate })),
10041018
fc ? fc.points.map((p) => ({ ...p, cost: p.cost * rate, lo: p.lo * rate, hi: p.hi * rate })) : null);
10051019
buildOwnToc();
10061020
}
10071021

1022+
// 小时级:过去 24h 实际(实心柱)+ 未来 24h 预测(浅色柱),中间分界线
1023+
function drawHourlyChart(wrap, past, next) {
1024+
const all = [...past.map((p) => ({ ...p, kind: "h" })), ...next.map((p) => ({ ...p, kind: "f" }))];
1025+
if (all.length < 4) { wrap.innerHTML = '<div class="chart-empty">小时数据不足</div>'; return; }
1026+
const W = 900, H = 200, L = 52, R = 12, T = 12, B = 26;
1027+
const iw = W - L - R, ih = H - T - B;
1028+
const n = all.length;
1029+
const slot = iw / n;
1030+
const bw = Math.max(2, Math.min(16, slot - 2));
1031+
const maxV = Math.max(...all.map((p) => p.kind === "f" ? Math.max(p.cost, p.hi || 0) : p.cost), 0.01);
1032+
const step = niceStep(maxV / 3);
1033+
const yMax = Math.max(step * Math.ceil((maxV * 1.05) / step), step);
1034+
const y = (v) => T + (1 - v / yMax) * ih;
1035+
1036+
let grid = "", labels = "";
1037+
for (let i = 0; i * step <= yMax + 1e-9; i++) {
1038+
const v = +(i * step).toFixed(6);
1039+
grid += `<line class="chart-grid" x1="${L}" y1="${y(v)}" x2="${W - R}" y2="${y(v)}"/>`;
1040+
labels += `<text class="chart-axis-label" x="${L - 6}" y="${y(v) + 3}" text-anchor="end">¥${v >= 100 ? Math.round(v) : v}</text>`;
1041+
}
1042+
const hourLabel = (t) => `${String(new Date(t).getHours()).padStart(2, "0")}:00`;
1043+
const cols = all.map((p, i) => {
1044+
const cx = L + slot * i + slot / 2;
1045+
const x0 = cx - bw / 2;
1046+
const yTop = y(p.cost);
1047+
const h = T + ih - yTop;
1048+
const r = Math.min(3, bw / 2, h);
1049+
const bar = h <= 0.4 ? "" :
1050+
`<path class="${p.kind === "f" ? "chart-bar-future" : "chart-bar"}" d="M${x0},${(yTop + r).toFixed(1)} a${r},${r} 0 0 1 ${r},-${r} h${(bw - 2 * r).toFixed(1)} a${r},${r} 0 0 1 ${r},${r} v${(h - r).toFixed(1)} h-${bw} z"/>`;
1051+
const lb = i % 4 === 0 ? `<text class="chart-axis-label" x="${cx}" y="${H - 8}" text-anchor="middle">${hourLabel(p.t)}</text>` : "";
1052+
return `<g>${bar}${lb}<rect class="u-hit" data-i="${i}" x="${L + slot * i}" y="${T}" width="${slot}" height="${ih}" fill="transparent"/></g>`;
1053+
}).join("");
1054+
// 「现在」分界线
1055+
const nowX = L + slot * past.length;
1056+
const divider = `<line class="chart-crosshair" x1="${nowX}" y1="${T}" x2="${nowX}" y2="${T + ih}"/>
1057+
<text class="chart-axis-label" x="${nowX + 4}" y="${T + 10}">现在</text>`;
1058+
1059+
wrap.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="未来 24 小时消费预测">${grid}${labels}${cols}${divider}</svg><div class="chart-tip"></div>`;
1060+
attachUsageTip(wrap, (i) => {
1061+
const p = all[i];
1062+
return p.kind === "h"
1063+
? `<div class="t">${hourLabel(p.t)}(实际)</div><div class="v">${cny4(p.cost)}</div>`
1064+
: `<div class="t">${hourLabel(p.t)}(预测)</div><div class="v">${cny4(p.cost)}</div><div class="r"><span>区间</span><b>${cny(p.lo)} ~ ${cny(p.hi)}</b></div>`;
1065+
});
1066+
}
1067+
10081068
// 右侧悬浮目录:扫描 [data-toc] 区块生成跳转项,滚动时高亮当前位置
10091069
function buildOwnToc() {
10101070
document.querySelector(".own-toc")?.remove();

public/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ button { font-family: inherit; cursor: pointer; }
314314
.seg button:hover { color: var(--text-primary); }
315315
.seg button.active { background: var(--bg-surface); color: var(--text-primary); font-weight: 500; box-shadow: var(--shadow-card); }
316316
.chart-bar { fill: var(--primary); }
317+
.chart-bar-future { fill: var(--primary); opacity: .32; }
317318
.bar-name { font-size: 11px; fill: var(--text-secondary); font-family: var(--font-ui); }
318319
.bar-value { font-size: 11px; font-weight: 500; fill: var(--text-primary); font-family: var(--font-ui); font-variant-numeric: tabular-nums; }
319320
.chart-tip .r { display: flex; justify-content: space-between; gap: 14px; color: var(--text-secondary); }

server.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
queryStation, queryStationUsage, queryOwnData, queryOwnChannels, queryOwnUsers,
99
dateStrInTz, parseDateLabel, fixedPurchases, STATION_TYPES,
1010
} from "./lib/providers.js";
11-
import { forecastDaily } from "./lib/forecast.js";
11+
import { forecastDaily, forecastHourly } from "./lib/forecast.js";
1212
import { SessionManager, verifyPassword } from "./lib/auth.js";
1313
import { History } from "./lib/history.js";
1414
import { evaluateStation } from "./lib/alerts.js";
@@ -554,6 +554,37 @@ app.get("/api/own/analytics", async (req, res) => {
554554
} catch { /* 拿不到就退化为不区分管理员 */ }
555555
const adminSet = new Set((ownUsers || []).filter((u) => u.role >= 10).map((u) => u.username));
556556

557+
// 小时级序列(近 14 天,缺时补 0,不含当前未完小时)→ 未来 24 小时预测
558+
const hourFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, hour12: false, hour: "2-digit" });
559+
const hodOf = (ms) => Number(hourFmt.format(new Date(ms))) % 24;
560+
const hmap = new Map();
561+
for (const r of modelRows) {
562+
const hk = Math.floor(r.t / 3600000) * 3600000;
563+
hmap.set(hk, (hmap.get(hk) || 0) + r.cost);
564+
}
565+
const lastFullHour = Math.floor(now / 3600000) * 3600000 - 3600000;
566+
const hourlyStart = Math.max(lastFullHour - 14 * 86400000, hmap.size ? Math.min(...hmap.keys()) : lastFullHour);
567+
const hourlySeries = [];
568+
for (let t = hourlyStart; t <= lastFullHour; t += 3600000) {
569+
hourlySeries.push({ t, cost: Math.round((hmap.get(t) || 0) * 10000) / 10000 });
570+
}
571+
const hf = forecastHourly(hourlySeries, hodOf, 24);
572+
let hourlyForecast = null;
573+
if (hf) {
574+
const todaySoFar = modelRows.filter((r) => r.t >= midnight).reduce((a, r) => a + r.cost, 0);
575+
// 今天预计全天 = 已发生 + 预测里落在今天的剩余小时
576+
const dayEndMs = midnight + 86400000;
577+
const restToday = hf.points.filter((p) => p.t < dayEndMs).reduce((a, p) => a + p.cost, 0);
578+
hourlyForecast = {
579+
past: hourlySeries.slice(-24),
580+
next: hf.points,
581+
next24Total: hf.next24Total,
582+
backtestWapePct: hf.backtestWapePct,
583+
todaySoFar: Math.round(todaySoFar * 100) / 100,
584+
todayEst: Math.round((todaySoFar + restToday) * 100) / 100,
585+
};
586+
}
587+
557588
const byUser = aggBy(userRows, "user").map((u) => ({ ...u, isAdmin: adminSet.has(u.user) }));
558589
// 收入 = 普通用户的期内消费;管理员/root 自己用不产生收入,但上游成本照付
559590
const incomeUsd = byUser.filter((u) => !u.isAdmin).reduce((a, u) => a + u.cost, 0);
@@ -573,6 +604,7 @@ app.get("/api/own/analytics", async (req, res) => {
573604
trend: [...tmap.values()].sort((a, b) => a.t - b.t),
574605
daily: daily.slice(-14),
575606
forecast: forecastDaily(daily, 7),
607+
hourly: hourlyForecast,
576608
profit: await computeProfit(own, incomeUsd, adminUsageUsd, { startMs, now, tz, range }),
577609
generatedAt: new Date().toISOString(),
578610
};

0 commit comments

Comments
 (0)