feat(forecast-ui): confidence band на ForecastChart — качественный, по уровню уверенности (#1926)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 1m59s

В §22-payload нет численных квантилей (p25/p75) — только качественный
confidence per-горизонт. Band строится полушириной из уровня уверенности
(high +-0.10 / medium +-0.20 / low +-0.32) вокруг фокусной линии дефицита
(base-сценарий, иначе single fallback), ECharts stack + stackStrategy
'all' (иначе отрицательная нижняя граница не стакается), кламп к [-1,1].
Легенда и тултип явно подписывают интервал как качественный, не p25/p75
— без выдуманных квантилей. Линии прогноза переведены на пунктир
(ui-conventions: prediction = dashed + band). Пункты 2 (velocity
proxy-disclosure) и 3 (h1) из #1926 уже были в main.
This commit is contained in:
bot-backend 2026-07-04 07:18:52 +05:00
parent 57d156973e
commit 2fd13d3cd3
3 changed files with 223 additions and 7 deletions

View file

@ -33,7 +33,11 @@ import type {
ScenarioKey,
} from "@/types/forecast";
import { fmtNum } from "./forecast-helpers";
import {
CONFIDENCE_RU,
confidenceBandHalfWidth,
fmtNum,
} from "./forecast-helpers";
interface Props {
report: ForecastReport;
@ -68,9 +72,18 @@ const SINGLE_LINE = "#374151";
// Values mirror ui-tokens.md exactly: --prediction-line, --fg-secondary,
// --border-strong. Keep in sync with ui-tokens.md by hand.
const PREDICTION_LINE = "#0EA5E9"; // --prediction-line
const PREDICTION_BAND = "rgba(14, 165, 233, 0.18)"; // --prediction-band
const FG_SECONDARY = "#5B6066"; // --fg-secondary
const BORDER_STRONG = "#D1D5DB"; // --border-strong
// #1926 — deficit axis bounds; band lower/upper are clamped to these so a wide
// low-confidence interval never paints past the scale.
const DEFICIT_MIN = -1;
const DEFICIT_MAX = 1;
const clampDeficit = (v: number): number =>
Math.max(DEFICIT_MIN, Math.min(DEFICIT_MAX, v));
// ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ──
interface TooltipParam {
seriesName?: string;
@ -78,6 +91,7 @@ interface TooltipParam {
axisValueLabel?: string;
marker?: string;
seriesType?: string;
dataIndex?: number;
}
function sortedUniqueHorizons(report: ForecastReport): number[] {
@ -111,6 +125,60 @@ function rateByHorizon(
return horizons.map((h) => byH.get(h) ?? null);
}
/**
* #1926 qualitative confidence band around a deficit line, aligned to the
* horizon axis. The §22 payload carries NO numeric quantiles (p25/p75) only a
* per-horizon `confidence` level so the band width is derived from that level
* (lower confidence wider), NOT from fabricated quantiles. Rendered as two
* stacked series (lower bound + width) so ECharts fills between them; the caller
* uses `stackStrategy: "all"` so a negative lower bound + positive width stack
* correctly on the signed [1, 1] axis.
*
* Returns `{ lower, width }` aligned to `horizons` (null where the deficit or its
* confidence is missing the band is simply absent at that horizon, never faked).
*/
interface DeficitBand {
lower: (number | null)[];
width: (number | null)[];
}
function bandByHorizon(
forecasts: DemandSupplyForecast[],
horizons: number[],
): DeficitBand {
const byH = new Map<number, DemandSupplyForecast>();
for (const f of forecasts) byH.set(f.horizon_months, f);
const lower: (number | null)[] = [];
const width: (number | null)[] = [];
for (const h of horizons) {
const f = byH.get(h);
if (f == null || f.deficit_index == null) {
lower.push(null);
width.push(null);
continue;
}
const half = confidenceBandHalfWidth(f.confidence);
const lo = clampDeficit(f.deficit_index - half);
const hi = clampDeficit(f.deficit_index + half);
lower.push(lo);
width.push(hi - lo);
}
return { lower, width };
}
/** Per-horizon confidence level for a forecast list (for the band tooltip). */
function confidenceByHorizon(
forecasts: DemandSupplyForecast[],
horizons: number[],
): (string | null)[] {
const byH = new Map<number, DemandSupplyForecast>();
for (const f of forecasts) byH.set(f.horizon_months, f);
return horizons.map((h) => {
const f = byH.get(h);
return f == null ? null : CONFIDENCE_RU[f.confidence];
});
}
// #1958 — близость к ±1 трактуем как clamp-floor: backend подрезает deficit_index
// к [1,1], и вырожденный (недифференцированный) прогноз садится на границу.
const DEGENERATE_CLAMP_EPS = 0.02;
@ -166,7 +234,8 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
const xLabels = horizons.map((h) => `${h} мес`);
// ── Deficit series ──────────────────────────────────────────────────────
// Per-scenario lines when available, else a single fallback line.
// Per-scenario lines when available, else a single fallback line. Forecast →
// dashed line per ui-conventions (prediction never a solid point-line).
const deficitSeries: Record<string, unknown>[] = hasScenarios
? effectiveScenarios.map((key) => ({
name: SCENARIO_RU[key],
@ -177,7 +246,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
symbolSize: 6,
smooth: false,
connectNulls: true,
lineStyle: { color: VIZ[key], width: 2 },
lineStyle: { color: VIZ[key], width: 2, type: "dashed" },
itemStyle: { color: VIZ[key] },
emphasis: { focus: "series" },
z: 3,
@ -192,12 +261,68 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
symbolSize: 6,
smooth: false,
connectNulls: true,
lineStyle: { color: SINGLE_LINE, width: 2 },
lineStyle: { color: SINGLE_LINE, width: 2, type: "dashed" },
itemStyle: { color: SINGLE_LINE },
z: 3,
},
];
// ── Confidence band (#1926) ──────────────────────────────────────────────
// Qualitative interval around the FOCUSED deficit line (base scenario, else
// the single fallback line) — banding all 3 scenario lines clutters. Width
// reflects the per-horizon confidence level (low → wider), NOT fabricated
// p25/p75; labelled «качественный» in legend + tooltip. Two stacked series
// (transparent lower bound + filled width) with `stackStrategy: "all"` so the
// fill sits between lower and upper on the signed [1, 1] axis.
const bandKey: ScenarioKey | null = hasScenarios
? (byScenario.base?.forecasts.length ?? 0) > 0
? "base"
: effectiveScenarios[0]
: null;
const bandSource: DemandSupplyForecast[] =
bandKey != null
? (byScenario[bandKey]?.forecasts ?? [])
: fm.forecasts_by_horizon;
const band = bandByHorizon(bandSource, horizons);
const bandConf = confidenceByHorizon(bandSource, horizons);
const hasBand = band.width.some((v) => v != null && v > 0);
const BAND_STACK = "deficit-band";
const bandSeries: Record<string, unknown>[] = hasBand
? [
{
// Lower bound — invisible line, just the stack base for the fill.
name: "band-lower",
type: "line",
yAxisIndex: 0,
data: band.lower,
stack: BAND_STACK,
stackStrategy: "all",
symbol: "none",
lineStyle: { opacity: 0 },
areaStyle: { opacity: 0 },
silent: true,
// Excluded from legend/tooltip (chrome-only helper series).
tooltip: { show: false },
z: 1,
},
{
// Width (upper lower) — the visible filled band.
name: "Интервал уверенности (качественный)",
type: "line",
yAxisIndex: 0,
data: band.width,
stack: BAND_STACK,
stackStrategy: "all",
symbol: "none",
lineStyle: { opacity: 0 },
areaStyle: { color: PREDICTION_BAND },
silent: true,
z: 1,
},
]
: [];
// ── Rate path (secondary axis, dashed = prediction) ─────────────────────
// One line only (base scenario preferred, else fallback forecast) — three
// rate lines clutter the deficit story.
@ -303,6 +428,11 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
});
}
// Helper band series are excluded from the tooltip body; instead the band's
// per-horizon confidence level is appended as a qualitative note (#1926).
const BAND_WIDTH_NAME = "Интервал уверенности (качественный)";
const BAND_SERIES_NAMES = new Set(["band-lower", BAND_WIDTH_NAME]);
return {
tooltip: {
trigger: "axis",
@ -313,19 +443,38 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
if (params.length === 0) return "";
const header = params[0]?.axisValueLabel ?? "";
const lines = params
.filter((p) => p.value != null)
.filter(
(p) =>
p.value != null && !BAND_SERIES_NAMES.has(p.seriesName ?? ""),
)
.map((p) => {
const v = p.value as number;
const isRate = p.seriesName === "Ставка";
const text = isRate ? `${fmtNum(v, 2)} %` : fmtNum(v, 2);
return `${p.marker ?? ""}${p.seriesName}: <b>${text}</b>`;
});
return [header, ...lines].join("<br/>");
// Qualitative confidence note for the band (no fabricated quantiles):
// interval width reflects the horizon's confidence level.
const idx = params[0]?.dataIndex;
const conf =
hasBand && typeof idx === "number" ? bandConf[idx] : null;
const note =
conf != null
? `Интервал: уверенность ${conf} (качественный, не p25/p75)`
: null;
return [header, ...lines, ...(note != null ? [note] : [])].join(
"<br/>",
);
},
textStyle: { fontSize: 12 },
},
legend: {
top: 0,
// The invisible lower-bound helper series is hidden from the legend; the
// filled «Интервал уверенности (качественный)» stays as the band key.
data: [...deficitSeries, ...rateSeries, ...bandSeries]
.map((s) => (s as { name?: string }).name)
.filter((n): n is string => n != null && n !== "band-lower"),
textStyle: { color: FG_SECONDARY, fontSize: 12 },
},
grid: { left: 56, right: hasRate ? 64 : 24, top: 40, bottom: 32 },
@ -337,7 +486,8 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
axisLine: { lineStyle: { color: BORDER_STRONG } },
},
yAxis,
series: [...deficitSeries, ...rateSeries],
// Band first (paints under), then deficit lines, then the rate path.
series: [...bandSeries, ...deficitSeries, ...rateSeries],
};
}, [
report,

View file

@ -0,0 +1,45 @@
/**
* Tests for `confidenceBandHalfWidth` issue #1926 (qualitative confidence band).
*
* The §22 forecast payload carries NO numeric quantiles (p25/p75) only a
* per-horizon qualitative confidence level. ui-conventions forbids showing a
* prediction without an interval, so ForecastChart draws a band whose width is
* derived from the confidence level: LOWER confidence WIDER band. This pins
* both the ordering (low > medium > high) and that the half-width stays within
* the deficit axis span so a low-confidence band can still be clamped to [1, 1].
*/
import { describe, expect, it } from "vitest";
import {
CONFIDENCE_BAND_HALFWIDTH,
confidenceBandHalfWidth,
} from "../forecast-helpers";
describe("confidenceBandHalfWidth — #1926 qualitative band width", () => {
it("returns the mapped half-width per level", () => {
expect(confidenceBandHalfWidth("high")).toBe(
CONFIDENCE_BAND_HALFWIDTH.high,
);
expect(confidenceBandHalfWidth("medium")).toBe(
CONFIDENCE_BAND_HALFWIDTH.medium,
);
expect(confidenceBandHalfWidth("low")).toBe(CONFIDENCE_BAND_HALFWIDTH.low);
});
it("lower confidence → wider band (low > medium > high)", () => {
expect(confidenceBandHalfWidth("low")).toBeGreaterThan(
confidenceBandHalfWidth("medium"),
);
expect(confidenceBandHalfWidth("medium")).toBeGreaterThan(
confidenceBandHalfWidth("high"),
);
});
it("every half-width is positive and fits within the deficit span (< 1)", () => {
for (const level of ["high", "medium", "low"] as const) {
const half = confidenceBandHalfWidth(level);
expect(half).toBeGreaterThan(0);
expect(half).toBeLessThan(1);
}
});
});

View file

@ -190,6 +190,27 @@ export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
return "danger";
}
/**
* #1926 качественная полуширина confidence-band для deficit-графика (ось [1, 1]).
*
* В §22-payload НЕТ численных квантилей (p25/p75) только качественный уровень
* уверенности per-горизонт (`DemandSupplyForecast.confidence` high|medium|low).
* ui-conventions требует показывать прогноз с интервалом (точка без диапазона
* «слишком уверенно»). Здесь строим band, ширина которого ОТРАЖАЕТ уверенность,
* а НЕ выдуманные квантили: ниже уверенность шире band. Значения доли шкалы
* дефицита (span 2): high ±0.10, medium ±0.20, low ±0.32. Band подписан в
* легенде/тултипе как качественный (по уровню уверенности), не как p25/p75.
*/
export const CONFIDENCE_BAND_HALFWIDTH: Record<ConfidenceLevel, number> = {
high: 0.1,
medium: 0.2,
low: 0.32,
};
export function confidenceBandHalfWidth(level: ConfidenceLevel): number {
return CONFIDENCE_BAND_HALFWIDTH[level];
}
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
/**