diff --git a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx index 2152b6ea..6ff06e03 100644 --- a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx @@ -19,15 +19,25 @@ import { Badge } from "@/components/ui/Badge"; import type { DemandSupplyForecast } from "@/types/forecast"; import { + deficitPegPlaque, deficitVariant, deficitWord, fmtNum, + isDeficitPegged, + type DeficitPegState, } from "./forecast-helpers"; interface Props { forecasts: DemandSupplyForecast[]; /** Horizon (мес) to emphasise as the current target — highlights its row. */ targetHorizon?: number; + /** + * B1 (#1958/#1959) — pegged-signal state (deficit_index уперся в край шкалы на + * всех горизонтах). Считается в Section6Forecast по общему ряду и прокидывается + * сюда + в 6.2, чтобы плашка «на пределе» не разъезжалась. Когда не задан — + * по умолчанию «не pegged» (плашка скрыта). + */ + peg?: DeficitPegState; } const TH_STYLE: React.CSSProperties = { @@ -53,7 +63,11 @@ const TD_STYLE: React.CSSProperties = { const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" }; -export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { +export function ForecastHorizonsBlock({ + forecasts, + targetHorizon, + peg, +}: Props) { if (forecasts.length === 0) { return (
@@ -68,6 +82,10 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { return (
|
{Math.round(f.projected_demand_units).toLocaleString("ru")}
@@ -165,6 +203,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
уверенность ≤ средней.
+ {/* B3 — почему «Прогноз предложения» здесь может быть 0, хотя в §4.3 виден
+ крупный pipeline: баланс §6 посегментный (класс/район), а §4.3 — класс-
+ агностичный будущий ввод. Снимает кажущееся противоречие 4.3 ↔ 6. */}
+ + Баланс считается по сегменту (класс/район); класс-агностичный будущий + pipeline из раздела 4.3 в посегментный баланс не входит — поэтому + будущее предложение здесь может быть 0. + + {/* Rate-sensitivity phrase — shared across horizons in current data; show the distinct phrases once each to avoid noisy repetition. */}
+ {deficitPegPlaque(sign)}
+
+ );
+}
+
function RateSensitivityNotes({
forecasts,
}: {
diff --git a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx
index 20a2980c..4c62fdc6 100644
--- a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx
+++ b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx
@@ -21,12 +21,21 @@ import {
deficitVariant,
deficitWord,
fmtNum,
+ type DeficitPegState,
} from "./forecast-helpers";
+import { DeficitPegPlaque } from "./ForecastHorizonsBlock";
interface Props {
scenarios: ReportScenarios;
scenariosSummary: ScenariosSummary | null;
targetHorizon: number;
+ /**
+ * B1 (#1958/#1959) — pegged-signal state (deficit_index уперся в край шкалы на
+ * всех горизонтах). Тот же объект, что у 6.1 — считается один раз в
+ * Section6Forecast. Когда pegged, над карточками показываем честную плашку
+ * «на пределе», т.к. −1.00 одинаков во всех сценариях.
+ */
+ peg?: DeficitPegState;
}
// Display order: conservative → base → aggressive (worst-to-best rate path).
@@ -80,7 +89,12 @@ export function ScenariosBlock({
scenarios,
scenariosSummary,
targetHorizon,
+ peg,
}: Props) {
+ const pegPlaque =
+ peg?.pegged && peg.sign != null ? (
+
+ {pegPlaque}
+ {pegPlaque}
-
+
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
затоварка и ниже индекс.
diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts
new file mode 100644
index 00000000..bb3f31f1
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-pegState.test.ts
@@ -0,0 +1,84 @@
+/**
+ * Tests for `deficitPegState` / `isDeficitPegged` — B1 honesty-guard (#1958/#1959).
+ *
+ * The §22 deficit_index is clamped to [−1, 1] and saturates at balance_ratio=0.5;
+ * when supply massively dwarfs demand (ratio ≪ 0.5 на всех горизонтах) every
+ * horizon clamps to exactly −1.00 (зеркально +1.00 для острого дефицита). Showing
+ * «−1.00 ×4» reads as broken / falsely precise, so Section6Forecast detects the
+ * saturated state once and renders a honest «на пределе» plaque over 6.1 / 6.2.
+ *
+ * The state is pegged only when EVERY present row is saturated AND same-signed —
+ * a single non-pegged / opposite-sign row means there IS differentiation.
+ */
+import { describe, expect, it } from "vitest";
+
+import { deficitPegState, isDeficitPegged } from "../forecast-helpers";
+
+function rows(...values: (number | null)[]) {
+ return values.map(
+ (deficit_index) =>
+ ({ deficit_index }) as {
+ deficit_index: number;
+ },
+ );
+}
+
+describe("isDeficitPegged — B1 saturation threshold (#1958/#1959)", () => {
+ it("is pegged at ±1.00 and within eps of the edge", () => {
+ expect(isDeficitPegged(-1)).toBe(true);
+ expect(isDeficitPegged(1)).toBe(true);
+ expect(isDeficitPegged(-0.99)).toBe(true);
+ expect(isDeficitPegged(0.995)).toBe(true);
+ });
+
+ it("is NOT pegged below the threshold (real differentiation)", () => {
+ expect(isDeficitPegged(-0.98)).toBe(false);
+ expect(isDeficitPegged(0.5)).toBe(false);
+ expect(isDeficitPegged(0)).toBe(false);
+ });
+});
+
+describe("deficitPegState — across horizons (#1958/#1959)", () => {
+ it("pegs at −1.00 on all horizons (затоварка) → sign −1", () => {
+ expect(deficitPegState(rows(-1, -1, -1, -1))).toEqual({
+ pegged: true,
+ sign: -1,
+ });
+ });
+
+ it("pegs at +1.00 on all horizons (острый дефицит) → sign +1", () => {
+ expect(deficitPegState(rows(1, 1, 1, 1))).toEqual({
+ pegged: true,
+ sign: 1,
+ });
+ });
+
+ it("does NOT peg when a row is mid-range (signal differentiates)", () => {
+ expect(deficitPegState(rows(-1, -1, -0.6, -1))).toEqual({
+ pegged: false,
+ sign: null,
+ });
+ });
+
+ it("does NOT peg when saturated rows have opposite signs", () => {
+ expect(deficitPegState(rows(-1, 1))).toEqual({
+ pegged: false,
+ sign: null,
+ });
+ });
+
+ it("ignores null deficit_index and pegs on present saturated rows only", () => {
+ expect(deficitPegState(rows(null, -1, null, -1))).toEqual({
+ pegged: true,
+ sign: -1,
+ });
+ });
+
+ it("is not pegged when no rows carry a deficit_index", () => {
+ expect(deficitPegState(rows(null, null))).toEqual({
+ pegged: false,
+ sign: null,
+ });
+ expect(deficitPegState([])).toEqual({ pegged: false, sign: null });
+ });
+});
diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts
index 1eefc03f..00ab8091 100644
--- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts
+++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts
@@ -7,6 +7,7 @@
import type { BadgeVariant } from "@/components/ui/Badge";
import type {
ConfidenceLevel,
+ DemandSupplyForecast,
FutureSupply,
ProductMixEntry,
} from "@/types/forecast";
@@ -14,6 +15,77 @@ import type {
/** Below this |deficit_index| we treat the market as balanced (neutral). */
export const DEFICIT_BALANCE_EPS = 0.05;
+/**
+ * #1958/#1959 honesty-guard — at/above this |deficit_index| the signal is
+ * SATURATED («упёрся» в край шкалы). The backend clamps deficit_index to [−1, 1]
+ * and saturates the index at balance_ratio=0.5; when supply massively dwarfs
+ * demand (ratio ≪ 0.5 на всех горизонтах) every horizon clamps to exactly −1.00
+ * (зеркально +1.00 для острого дефицита). Showing the SAME «−1.00» ×4 reads as
+ * broken / falsely precise — so we mark such values «на пределе» and раскрываем
+ * честную плашку. Mirrors `ForecastChart.DEGENERATE_CLAMP_EPS` (0.02 →
+ * |value| ≥ 0.98) but tightened to 0.99 per the §6 brief (а у графика свой порог).
+ */
+export const DEFICIT_PEG_THRESHOLD = 0.99;
+
+/** Saturated (clamp-floor) signal? `|deficit_index| ≥ DEFICIT_PEG_THRESHOLD`. */
+export function isDeficitPegged(deficitIndex: number): boolean {
+ return Math.abs(deficitIndex) >= DEFICIT_PEG_THRESHOLD;
+}
+
+/**
+ * Pegged-signal state across a set of horizon rows (HONESTY-GUARD, #1958/#1959).
+ * The signal is «на пределе шкалы» only when EVERY present deficit_index is
+ * saturated (|value| ≥ 0.99) AND all саме знака — then «−1.00 ×4» не различает
+ * горизонты и плашка честнее точного числа. A single non-pegged / opposite-sign
+ * row means there IS differentiation → not pegged (plain table tells the story).
+ *
+ * Returns `{ pegged, sign }`:
+ * pegged — render the honesty plaque + mark values «на пределе»;
+ * sign — −1 затоварка (supply ≫ demand) / +1 острый дефицит, или null when
+ * не pegged. Drives which RU plaque text to show (zeroкально).
+ * PURE (no JSX) — single source of truth shared by 6.1 (table) and 6.2 (scenarios).
+ */
+export interface DeficitPegState {
+ pegged: boolean;
+ sign: -1 | 1 | null;
+}
+
+export function deficitPegState(
+ forecasts: Pick
+ {/* B2 — участок не под жильё: прогноз спроса/предложения справочный.
+ Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
+ {gateCaveat &&
))}
+
+ );
+}
+
// ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ─────────────
//
// Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без
@@ -533,7 +603,9 @@ function DeficitLegend() {
+
+ Участок не предназначен под жильё (см. блокеры) — прогноз спроса и
+ предложения носит справочный характер.
+
+
+ {caveat}
+
+
+ |