fix(forecast): §6 honesty layer — pegged deficit, non-residential caveat, segment/pipeline note (#1953) (#2096)
This commit is contained in:
parent
86f0797499
commit
1337fadb49
5 changed files with 331 additions and 7 deletions
|
|
@ -19,15 +19,25 @@
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import type { DemandSupplyForecast } from "@/types/forecast";
|
import type { DemandSupplyForecast } from "@/types/forecast";
|
||||||
import {
|
import {
|
||||||
|
deficitPegPlaque,
|
||||||
deficitVariant,
|
deficitVariant,
|
||||||
deficitWord,
|
deficitWord,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
|
isDeficitPegged,
|
||||||
|
type DeficitPegState,
|
||||||
} from "./forecast-helpers";
|
} from "./forecast-helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
forecasts: DemandSupplyForecast[];
|
forecasts: DemandSupplyForecast[];
|
||||||
/** Horizon (мес) to emphasise as the current target — highlights its row. */
|
/** Horizon (мес) to emphasise as the current target — highlights its row. */
|
||||||
targetHorizon?: number;
|
targetHorizon?: number;
|
||||||
|
/**
|
||||||
|
* B1 (#1958/#1959) — pegged-signal state (deficit_index уперся в край шкалы на
|
||||||
|
* всех горизонтах). Считается в Section6Forecast по общему ряду и прокидывается
|
||||||
|
* сюда + в 6.2, чтобы плашка «на пределе» не разъезжалась. Когда не задан —
|
||||||
|
* по умолчанию «не pegged» (плашка скрыта).
|
||||||
|
*/
|
||||||
|
peg?: DeficitPegState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TH_STYLE: React.CSSProperties = {
|
const TH_STYLE: React.CSSProperties = {
|
||||||
|
|
@ -53,7 +63,11 @@ const TD_STYLE: React.CSSProperties = {
|
||||||
|
|
||||||
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
|
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) {
|
if (forecasts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
||||||
|
|
@ -68,6 +82,10 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{/* B1 — честная плашка, когда индекс упёрся в край шкалы на всех горизонтах:
|
||||||
|
точное «−1.00 ×4» не различает горизонты, оценка грубая. */}
|
||||||
|
{peg?.pegged && peg.sign != null && <DeficitPegPlaque sign={peg.sign} />}
|
||||||
|
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -103,7 +121,11 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
<td
|
<td
|
||||||
style={
|
style={
|
||||||
isTarget
|
isTarget
|
||||||
? { ...TD_STYLE, fontWeight: 600, color: "var(--accent)" }
|
? {
|
||||||
|
...TD_STYLE,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--accent)",
|
||||||
|
}
|
||||||
: TD_STYLE
|
: TD_STYLE
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -137,6 +159,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
>
|
>
|
||||||
{deficitWord(di)}
|
{deficitWord(di)}
|
||||||
</span>
|
</span>
|
||||||
|
{/* B1 — значение на краю шкалы: помечаем «на пределе», чтобы
|
||||||
|
одинаковые −1.00 ×4 не читались как ложно-точные. */}
|
||||||
|
{peg?.pegged && isDeficitPegged(di) && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
на пределе
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={NUM_TD}>
|
<td style={NUM_TD}>
|
||||||
{Math.round(f.projected_demand_units).toLocaleString("ru")}
|
{Math.round(f.projected_demand_units).toLocaleString("ru")}
|
||||||
|
|
@ -165,6 +203,22 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
уверенность ≤ средней.
|
уверенность ≤ средней.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* B3 — почему «Прогноз предложения» здесь может быть 0, хотя в §4.3 виден
|
||||||
|
крупный pipeline: баланс §6 посегментный (класс/район), а §4.3 — класс-
|
||||||
|
агностичный будущий ввод. Снимает кажущееся противоречие 4.3 ↔ 6. */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: "16px",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Баланс считается по сегменту (класс/район); класс-агностичный будущий
|
||||||
|
pipeline из раздела 4.3 в посегментный баланс не входит — поэтому
|
||||||
|
будущее предложение здесь может быть 0.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Rate-sensitivity phrase — shared across horizons in current data; show
|
{/* Rate-sensitivity phrase — shared across horizons in current data; show
|
||||||
the distinct phrases once each to avoid noisy repetition. */}
|
the distinct phrases once each to avoid noisy repetition. */}
|
||||||
<RateSensitivityNotes forecasts={rows} />
|
<RateSensitivityNotes forecasts={rows} />
|
||||||
|
|
@ -172,6 +226,32 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B1 (#1958/#1959) — honest plaque shown above 6.1 / inside 6.2 when the deficit
|
||||||
|
* signal is saturated at the scale edge on every horizon. Exported so 6.2
|
||||||
|
* (ScenariosBlock) renders the identical plaque (one visual source of truth). The
|
||||||
|
* RU text comes from `deficitPegPlaque` (helper) — never hardcoded per block.
|
||||||
|
* Warn pattern (--warn-soft / role="note") как у остальных caveat-плашек §6.
|
||||||
|
*/
|
||||||
|
export function DeficitPegPlaque({ sign }: { sign: -1 | 1 }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--warn-soft, #FEF3C7)",
|
||||||
|
border: "1px solid var(--warn, #9A6700)",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: "16px",
|
||||||
|
color: "var(--warn, #9A6700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deficitPegPlaque(sign)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RateSensitivityNotes({
|
function RateSensitivityNotes({
|
||||||
forecasts,
|
forecasts,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,21 @@ import {
|
||||||
deficitVariant,
|
deficitVariant,
|
||||||
deficitWord,
|
deficitWord,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
|
type DeficitPegState,
|
||||||
} from "./forecast-helpers";
|
} from "./forecast-helpers";
|
||||||
|
import { DeficitPegPlaque } from "./ForecastHorizonsBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scenarios: ReportScenarios;
|
scenarios: ReportScenarios;
|
||||||
scenariosSummary: ScenariosSummary | null;
|
scenariosSummary: ScenariosSummary | null;
|
||||||
targetHorizon: number;
|
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).
|
// Display order: conservative → base → aggressive (worst-to-best rate path).
|
||||||
|
|
@ -80,7 +89,12 @@ export function ScenariosBlock({
|
||||||
scenarios,
|
scenarios,
|
||||||
scenariosSummary,
|
scenariosSummary,
|
||||||
targetHorizon,
|
targetHorizon,
|
||||||
|
peg,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const pegPlaque =
|
||||||
|
peg?.pegged && peg.sign != null ? (
|
||||||
|
<DeficitPegPlaque sign={peg.sign} />
|
||||||
|
) : null;
|
||||||
const present = SCENARIO_ORDER.filter(
|
const present = SCENARIO_ORDER.filter(
|
||||||
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
|
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
|
||||||
);
|
);
|
||||||
|
|
@ -109,6 +123,7 @@ export function ScenariosBlock({
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{pegPlaque}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|
@ -157,6 +172,7 @@ export function ScenariosBlock({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{pegPlaque}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
* advisory disclaimer (footer)
|
* advisory disclaimer (footer)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LineChart } from "lucide-react";
|
import { AlertTriangle, LineChart } from "lucide-react";
|
||||||
|
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
import { KpiCard } from "@/components/site-finder/KpiCard";
|
import { KpiCard } from "@/components/site-finder/KpiCard";
|
||||||
|
|
@ -40,6 +40,7 @@ import { ForecastMetaLine } from "./ForecastMetaLine";
|
||||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||||
import {
|
import {
|
||||||
CONFIDENCE_RU,
|
CONFIDENCE_RU,
|
||||||
|
deficitPegState,
|
||||||
deficitVariant,
|
deficitVariant,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
hasSupplySignal,
|
hasSupplySignal,
|
||||||
|
|
@ -256,6 +257,17 @@ function ForecastReady({
|
||||||
hasScoring ||
|
hasScoring ||
|
||||||
hasFutureSupplyDetail;
|
hasFutureSupplyDetail;
|
||||||
|
|
||||||
|
// B1 (#1958/#1959) — pegged-signal honesty-guard. deficit_index упирается в край
|
||||||
|
// шкалы (|value| ≥ 0.99) на ВСЕХ горизонтах того же знака → «−1.00 ×4» не
|
||||||
|
// различает горизонты. Считаем ОДИН раз тут (источник — общий ряд горизонтов) и
|
||||||
|
// прокидываем в 6.1 (таблица) и 6.2 (сценарии), чтобы плашки не разъезжались.
|
||||||
|
const peg = deficitPegState(fm.forecasts_by_horizon);
|
||||||
|
|
||||||
|
// B2 — gate-caveat участка (см. блокеры: «Нельзя строить МКД»). Бэкенд проставляет
|
||||||
|
// product_tz.gate_caveat, когда жильё под запретом → весь §6-прогноз справочный.
|
||||||
|
// Поднимаем его на уровень ВСЕЙ секции (был бы закопан в 6.4) — заметная плашка.
|
||||||
|
const gateCaveat = pt?.gate_caveat?.trim() || null;
|
||||||
|
|
||||||
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||||||
const subtitle = fm.summary ?? undefined;
|
const subtitle = fm.summary ?? undefined;
|
||||||
|
|
||||||
|
|
@ -271,6 +283,10 @@ function ForecastReady({
|
||||||
gap: 24,
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* B2 — участок не под жильё: прогноз спроса/предложения справочный.
|
||||||
|
Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
|
||||||
|
{gateCaveat && <NonResidentialCaveat caveat={gateCaveat} />}
|
||||||
|
|
||||||
{/* Export / share forecast report (EPIC #959) */}
|
{/* Export / share forecast report (EPIC #959) */}
|
||||||
<ForecastExportButtons cad={cad} />
|
<ForecastExportButtons cad={cad} />
|
||||||
|
|
||||||
|
|
@ -330,9 +346,7 @@ function ForecastReady({
|
||||||
(exec.overall_confidence ?? kn.confidence) != null
|
(exec.overall_confidence ?? kn.confidence) != null
|
||||||
? CONFIDENCE_RU[
|
? CONFIDENCE_RU[
|
||||||
(exec.overall_confidence ?? kn.confidence) as
|
(exec.overall_confidence ?? kn.confidence) as
|
||||||
| "high"
|
"high" | "medium" | "low"
|
||||||
| "medium"
|
|
||||||
| "low"
|
|
||||||
]
|
]
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +384,7 @@ function ForecastReady({
|
||||||
<ForecastHorizonsBlock
|
<ForecastHorizonsBlock
|
||||||
forecasts={fm.forecasts_by_horizon}
|
forecasts={fm.forecasts_by_horizon}
|
||||||
targetHorizon={targetHorizon}
|
targetHorizon={targetHorizon}
|
||||||
|
peg={peg}
|
||||||
/>
|
/>
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
@ -381,6 +396,7 @@ function ForecastReady({
|
||||||
scenarios={report.scenarios}
|
scenarios={report.scenarios}
|
||||||
scenariosSummary={fm.scenarios_summary}
|
scenariosSummary={fm.scenarios_summary}
|
||||||
targetHorizon={targetHorizon}
|
targetHorizon={targetHorizon}
|
||||||
|
peg={peg}
|
||||||
/>
|
/>
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
@ -457,6 +473,60 @@ function ForecastReady({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NonResidentialCaveat — участок не под жильё (B2) ──────────────────────────
|
||||||
|
//
|
||||||
|
// Заметная warn-плашка В ВЕРХУ §6: бэкенд проставил product_tz.gate_caveat, когда
|
||||||
|
// gate=«Нельзя строить МКД». Весь прогноз спроса/предложения тогда — справочный
|
||||||
|
// (считается по сегменту рынка, но строить жильё на участке нельзя). Паттерн
|
||||||
|
// --warn-soft / role="note" как у СЗЗ-баннера / VelocityBlock-caveat; иконка
|
||||||
|
// Lucide (НЕ emoji), токены с fallback. `caveat` — готовый RU-текст от бэкенда;
|
||||||
|
// под ним — наша микрокопия-следствие «прогноз носит справочный характер».
|
||||||
|
|
||||||
|
function NonResidentialCaveat({ caveat }: { caveat: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--warn-soft, #FEF3C7)",
|
||||||
|
border: "1px solid var(--warn, #9A6700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
size={18}
|
||||||
|
aria-hidden
|
||||||
|
style={{ flexShrink: 0, marginTop: 1, color: "var(--warn, #9A6700)" }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--warn, #9A6700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Участок не предназначен под жильё (см. блокеры) — прогноз спроса и
|
||||||
|
предложения носит справочный характер.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caveat}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ─────────────
|
// ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ─────────────
|
||||||
//
|
//
|
||||||
// Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без
|
// Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без
|
||||||
|
|
@ -533,7 +603,9 @@ function DeficitLegend() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}>
|
<span
|
||||||
|
style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}
|
||||||
|
>
|
||||||
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
|
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
|
||||||
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
|
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
|
||||||
затоварка и ниже индекс.
|
затоварка и ниже индекс.
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import type {
|
import type {
|
||||||
ConfidenceLevel,
|
ConfidenceLevel,
|
||||||
|
DemandSupplyForecast,
|
||||||
FutureSupply,
|
FutureSupply,
|
||||||
ProductMixEntry,
|
ProductMixEntry,
|
||||||
} from "@/types/forecast";
|
} from "@/types/forecast";
|
||||||
|
|
@ -14,6 +15,77 @@ import type {
|
||||||
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
||||||
export const DEFICIT_BALANCE_EPS = 0.05;
|
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<DemandSupplyForecast, "deficit_index">[],
|
||||||
|
): DeficitPegState {
|
||||||
|
const present = forecasts
|
||||||
|
.map((f) => f.deficit_index)
|
||||||
|
.filter((v): v is number => v != null);
|
||||||
|
if (present.length === 0) return { pegged: false, sign: null };
|
||||||
|
const allPegged = present.every(isDeficitPegged);
|
||||||
|
if (!allPegged) return { pegged: false, sign: null };
|
||||||
|
const allSameSign =
|
||||||
|
present.every((v) => v > 0) || present.every((v) => v < 0);
|
||||||
|
if (!allSameSign) return { pegged: false, sign: null };
|
||||||
|
return { pegged: true, sign: present[0] > 0 ? 1 : -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human RU plaque text for a pegged deficit signal (#1958/#1959). Финдиректору
|
||||||
|
* объясняем простым языком, что точное «−1.00 ×4» — артефакт упора в край шкалы
|
||||||
|
* (рынок перенасыщен), а не различие горизонтов. Зеркально для +1.0 — острый
|
||||||
|
* дефицит. Источник правды для 6.1 и 6.2 (не дублировать строку в TSX).
|
||||||
|
*/
|
||||||
|
export function deficitPegPlaque(sign: -1 | 1): string {
|
||||||
|
if (sign < 0) {
|
||||||
|
return (
|
||||||
|
"Сигнал на пределе шкалы: предложение во много раз превышает спрос на всех " +
|
||||||
|
"горизонтах — сильная затоварка. Точное −1.00 не различает горизонты (рынок " +
|
||||||
|
"перенасыщен), оценка грубая."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Сигнал на пределе шкалы: спрос во много раз превышает предложение на всех " +
|
||||||
|
"горизонтах — острый дефицит. Точное +1.00 не различает горизонты (рынок " +
|
||||||
|
"сильно недонасыщен), оценка грубая."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deficit semantics (HARD): >0 недонасыщенность (повод строить, success);
|
* Deficit semantics (HARD): >0 недонасыщенность (повод строить, success);
|
||||||
* <0 затоварка (warn/danger); ≈0 баланс (neutral).
|
* <0 затоварка (warn/danger); ≈0 баланс (neutral).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue