Из live-walkthrough Anton'а (66:41:0702017:131): дефицит −1.00 «затоварка» на всех
горизонтах — данные КОРРЕКТНЫ (Кировский Комфорт: 12156 доступных ÷ ~100/мес = ~155 мес,
genuine затоварка при ставке 14.5%), но презентация ложно-точная/сбивающая.
B1: deficitPegState helper — детект насыщения (|deficit_index|≥0.99 одного знака на
ВСЕХ горизонтах) → честная плашка «сигнал на пределе шкалы, −1.00 не различает
горизонты, оценка грубая» над 6.1/6.2 + маркер «НА ПРЕДЕЛЕ» вместо ложно-точного ×4.
B2: поднял product_tz.gate_caveat в заметную плашку ВВЕРХ §6 (раньше не рендерился
нигде) — «участок не под жильё → прогноз справочный».
B3: микрокопия почему future=0 (посегментный баланс vs класс-агностичный pipeline §4.3).
Edit-only, контракт не менялся. +8 unit-тестов на pegState.
351 lines
18 KiB
TypeScript
351 lines
18 KiB
TypeScript
/**
|
||
* Shared helpers for Section 6 (forecast) blocks — deficit semantics, confidence
|
||
* mapping, RU number formatting. Kept framework-agnostic (no JSX) so the three
|
||
* forecast blocks share one source of truth.
|
||
*/
|
||
|
||
import type { BadgeVariant } from "@/components/ui/Badge";
|
||
import type {
|
||
ConfidenceLevel,
|
||
DemandSupplyForecast,
|
||
FutureSupply,
|
||
ProductMixEntry,
|
||
} from "@/types/forecast";
|
||
|
||
/** 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<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);
|
||
* <0 затоварка (warn/danger); ≈0 баланс (neutral).
|
||
*/
|
||
export function deficitVariant(deficitIndex: number): BadgeVariant {
|
||
if (deficitIndex > DEFICIT_BALANCE_EPS) return "success";
|
||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "danger";
|
||
return "neutral";
|
||
}
|
||
|
||
export function deficitWord(deficitIndex: number): string {
|
||
if (deficitIndex > DEFICIT_BALANCE_EPS) return "недонасыщенность";
|
||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "затоварка";
|
||
return "баланс";
|
||
}
|
||
|
||
/**
|
||
* Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить»
|
||
* (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend
|
||
* `report_assembler._build_signal` — фронт-fallback, когда mix-ячейка не несёт
|
||
* готового `signal` (старый прогон). Семантика та же, что у `deficitWord`.
|
||
*/
|
||
export function deficitSignalWord(deficitIndex: number): string {
|
||
if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить";
|
||
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать";
|
||
return "баланс";
|
||
}
|
||
|
||
// ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ──────────
|
||
|
||
/** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */
|
||
export interface ClassDeficit {
|
||
obj_class: string;
|
||
/** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */
|
||
meanDeficitIndex: number | null;
|
||
/** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */
|
||
nWithIndex: number;
|
||
}
|
||
|
||
/**
|
||
* Агрегировать `mix` (квартирография — ячейки формат×класс) в per-класс дефицит:
|
||
* среднее deficit_index по форматам каждого класса. Зеркало backend
|
||
* `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт
|
||
* мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения —
|
||
* `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index.
|
||
*
|
||
* Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге);
|
||
* классы без obj_class игнорируются (нечего агрегировать — НЕ фабрикуем класс).
|
||
* Класс, у которого ни у одной ячейки нет индекса → meanDeficitIndex=null (фронт
|
||
* рисует «тонкие данные», НЕ 0).
|
||
*/
|
||
export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] {
|
||
const byClass = new Map<string, number[]>();
|
||
for (const m of mix) {
|
||
if (m.obj_class == null) continue;
|
||
const list = byClass.get(m.obj_class) ?? [];
|
||
if (m.deficit_index != null) list.push(m.deficit_index);
|
||
byClass.set(m.obj_class, list);
|
||
}
|
||
const out: ClassDeficit[] = [];
|
||
for (const [obj_class, values] of byClass) {
|
||
const meanDeficitIndex =
|
||
values.length > 0
|
||
? values.reduce((a, b) => a + b, 0) / values.length
|
||
: null;
|
||
out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length });
|
||
}
|
||
// DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC.
|
||
out.sort((a, b) => {
|
||
const av = a.meanDeficitIndex;
|
||
const bv = b.meanDeficitIndex;
|
||
if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class);
|
||
if (av == null) return 1;
|
||
if (bv == null) return -1;
|
||
if (bv !== av) return bv - av;
|
||
return a.obj_class.localeCompare(b.obj_class);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the
|
||
* magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar
|
||
* (colour carries the sign via `deficitVariant`). Clamped to [0, 100].
|
||
*/
|
||
export function deficitBarWidthPct(deficitIndex: number): number {
|
||
const pct = Math.abs(deficitIndex) * 100;
|
||
return Math.max(0, Math.min(100, pct));
|
||
}
|
||
|
||
// ── Confidence ────────────────────────────────────────────────────────────────
|
||
|
||
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
||
high: "высокая",
|
||
medium: "средняя",
|
||
low: "низкая",
|
||
};
|
||
|
||
export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
|
||
if (level === "high") return "success";
|
||
if (level === "medium") return "warning";
|
||
return "danger";
|
||
}
|
||
|
||
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
|
||
|
||
/**
|
||
* Product-score semantics (HARD): every §14.2 score is ∈ [0,1] with «выше =
|
||
* лучше для девелопера» (risk scores supply_risk / future_competition /
|
||
* mortgage_sensitivity are pre-inverted at the source — so score>0.5 =
|
||
* favourable). The backend anchors quality on 0.5 = баланс/midpoint
|
||
* (`product_scoring.py:127` `_MARKET_FIT_MIDPOINT = 0.5`; inverted-risk reasons
|
||
* flip favourable/unfavourable at risk<0.5 → score>0.5), so the gradient is
|
||
* anchored on 0.5 too: a narrow ±0.05 neutral band straddles the balance point —
|
||
* ≥0.55 success / 0.45–0.55 warning / <0.45 danger. This is NOT the deficit
|
||
* mapping (signed [−1,1]) — do not reuse deficitVariant here.
|
||
*/
|
||
export const SCORE_GOOD_THRESHOLD = 0.55;
|
||
export const SCORE_WEAK_THRESHOLD = 0.45;
|
||
|
||
export function scoreVariant(value: number): BadgeVariant {
|
||
if (value >= SCORE_GOOD_THRESHOLD) return "success";
|
||
if (value >= SCORE_WEAK_THRESHOLD) return "warning";
|
||
return "danger";
|
||
}
|
||
|
||
/**
|
||
* Bar width (%) for a 0..1 score on a 0..100 scale (direct, no magnitude trick —
|
||
* the score is already unsigned 0..1). Clamped to [0, 100].
|
||
*/
|
||
export function scoreBarWidthPct(value: number): number {
|
||
return Math.max(0, Math.min(100, value * 100));
|
||
}
|
||
|
||
/**
|
||
* RU labels for the 10 §14.2 product-score keys (#985). The MD/PDF exporters
|
||
* render the raw English key — these are the established in-app RU names, kept
|
||
* here so the block and any future reuse share one source of truth. Unknown
|
||
* keys fall back to the raw key (graceful).
|
||
*/
|
||
export const PRODUCT_SCORE_RU: Record<string, string> = {
|
||
market_fit: "Соответствие рынку",
|
||
demand: "Спрос",
|
||
// #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению».
|
||
supply_risk: "Запас по предложению",
|
||
future_competition: "Будущая конкуренция",
|
||
price_feasibility: "Доступность цены",
|
||
infra_fit: "Инфраструктура",
|
||
mortgage_sensitivity: "Чувствительность к ставке",
|
||
differentiation: "Дифференциация",
|
||
commercial: "Коммерция",
|
||
confidence: "Надёжность данных",
|
||
};
|
||
|
||
/**
|
||
* RU labels for the 6 §25 special-index keys (#986). The per-entry `label` field
|
||
* is a value-descriptor («6 мес» / «Комфорт»), not a metric name, so it is shown
|
||
* as supplementary context — these are the metric names. Unknown keys fall back
|
||
* to the raw key (graceful).
|
||
*/
|
||
export const SPECIAL_INDEX_RU: Record<string, string> = {
|
||
launch_window: "Окно запуска",
|
||
product_void: "Белые пятна продукта",
|
||
cannibalization: "Каннибализация",
|
||
competitor_strength: "Сила конкурентов",
|
||
artificial_demand: "Искусственный спрос",
|
||
cost_of_error: "Цена ошибки",
|
||
};
|
||
|
||
/**
|
||
* #1963 — однострочное «что это + куда лучше» для 6 спец-индексов. Раньше индексы
|
||
* показывались только сырым именем + числом без объяснения, что значит «выше» —
|
||
* а направление у них РАЗНОЕ (часть растёт к лучшему, часть к худшему). Здесь
|
||
* проговариваем смысл и желаемую сторону. Источник правды до появления поля от
|
||
* бэкенда — этот map (graceful: незнакомый ключ → пусто, строка просто без подписи).
|
||
*/
|
||
export const SPECIAL_INDEX_DESC: Record<string, string> = {
|
||
product_void:
|
||
"Доля незакрытых ниш (форматов с дефицитом) — выше лучше: больше места для нового продукта.",
|
||
cost_of_error:
|
||
"Цена ошибки выхода = риск затоварки × средний чек лота — ниже лучше: дешевле ошибиться.",
|
||
launch_window:
|
||
"Лучший момент для старта по горизонтам прогноза — раньше окно, тем лучше.",
|
||
cannibalization:
|
||
"Насколько проект отъедает спрос у соседних ЖК того же класса — ниже лучше.",
|
||
artificial_demand:
|
||
"Доля спроса, держащегося на льготной ипотеке (уязвима к смене ставки) — ниже лучше.",
|
||
competitor_strength:
|
||
"Сила топ-конкурентов рядом — ниже лучше: слабее конкуренты, проще выйти.",
|
||
};
|
||
|
||
/**
|
||
* #1963 — вынести §-ссылки («§10.4», «§9.6», «§16») из основного reason в отдельную
|
||
* деталь/тултип. Возвращает {clean, refs}: `clean` — текст без §-маркеров и пустых
|
||
* скобок, `refs` — извлечённые ссылки (через запятую) для tooltip. PURE.
|
||
*/
|
||
export function stripSectionRefs(reason: string): {
|
||
clean: string;
|
||
refs: string | null;
|
||
} {
|
||
// Match «§9.6» / «§10.4» / «§13.6a» but NOT the sentence-final dot after it.
|
||
// Trailing letter only when the ref ends at a separator/EOL (не «съедает» букву
|
||
// следующего слова, если § приклеена без пробела).
|
||
const SECTION_RE = /§\s*\d+(?:\.\d+)*(?:[a-zа-я](?=$|[\s.,;)]))?/gi;
|
||
const refs = reason.match(SECTION_RE) ?? [];
|
||
let clean = reason.replace(SECTION_RE, "");
|
||
// Подчистить осиротевшие скобки/запятые/двойные пробелы после выреза ссылки.
|
||
clean = clean
|
||
.replace(/\(\s*[,;]?\s*\)/g, "")
|
||
.replace(/,\s*\)/g, ")")
|
||
.replace(/\(\s+/g, "(")
|
||
.replace(/\s+\)/g, ")")
|
||
.replace(/\s{2,}/g, " ")
|
||
.replace(/\s+([.,;)])/g, "$1")
|
||
.trim();
|
||
return { clean, refs: refs.length > 0 ? refs.join(", ") : null };
|
||
}
|
||
|
||
// ── Future supply (§9.3 — 6.6 evidence panel) ──────────────────────────────────
|
||
|
||
/**
|
||
* Does the §9.3 future-supply payload carry a GENUINELY-COMPUTED pressure signal
|
||
* worth rendering the supply panel for? Single source of truth — gates both the
|
||
* 6.6 block's panel and Section6Forecast's 6.6 sub-block (so they can't drift).
|
||
*
|
||
* Gates on the `_round_or_none` metrics ONLY (index / months_of_pressure /
|
||
* future_units_by_horizon / monthly_absorption_units) — DELIBERATELY excludes
|
||
* `open_units` / `hidden_units`. Those are integer stocks that are 0-NOT-NULL at
|
||
* the source: when `supply_layers` is empty (e.g. before the Monday 06:00 worker
|
||
* loads it) the SQL returns 0 rows → open/hidden = 0 while every computed metric
|
||
* is null. Triggering on open/hidden would render «Открытый сток: 0 ед. / Скрытый
|
||
* запас: 0 ед.», falsely reading as "zero future supply = safe" when the truth is
|
||
* "supply data not loaded yet" (null ≠ 0). Gating on the computed metrics keeps
|
||
* the panel OFF until a real pressure index exists; open/hidden are still DISPLAYED
|
||
* once it does (a genuine 0 then is honest).
|
||
*/
|
||
export function hasSupplySignal(fs: FutureSupply | null): boolean {
|
||
if (fs == null) return false;
|
||
const b = fs.breakdown;
|
||
return (
|
||
fs.index != null ||
|
||
b.months_of_pressure != null ||
|
||
b.future_units_by_horizon != null ||
|
||
b.monthly_absorption_units != null
|
||
);
|
||
}
|
||
|
||
// ── Number formatting ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Fixed-decimal RU number (тонкая неразрывная группировка тысяч). Нормализует
|
||
* ведущий знак минуса в Unicode «−» (U+2212) per ui-microcopy (≥ ≤ ± Unicode).
|
||
* `.replace` бьёт только первое вхождение = ведущий знак (RU-группировка — NBSP,
|
||
* не дефис), так что числовая часть не затрагивается.
|
||
*/
|
||
export function fmtNum(value: number, digits = 1): string {
|
||
return value
|
||
.toLocaleString("ru", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
})
|
||
.replace("-", "−");
|
||
}
|