fix(sf-05): clamp sold_pct_of_supply at 100% + add is_oversold flag

Symptom: sold_pct_of_supply = sum_deals_24mo / supply_count_snapshot * 100
yields >100% (e.g. 199%) for fast-selling small formats due to incompatible
time windows (24-month deals vs point-in-time supply snapshot).

Option A (hotfix): clamp to 100.0, expose is_oversold=True for UI badge.

- best_layouts.py: compute sold_pct_raw, clamp with min(..., 100.0),
  set is_oversold = raw > 100%
- parcel.py TopLayoutRow: add is_oversold: bool field
- best-layouts.ts TopLayoutRow: add is_oversold: boolean
- BestLayoutsBlock.tsx: show warn badge ">100%" when is_oversold=True
- tests: two new cases — raw 199% clamps + is_oversold=True;
  raw 50% passes through + is_oversold=False

Closes (epic part) #271 item 5
This commit is contained in:
lekss361 2026-05-17 11:57:29 +03:00
parent 31b13c18b1
commit 247c252b04
5 changed files with 101 additions and 6 deletions

View file

@ -192,6 +192,8 @@ class Competitor(BaseModel):
lat: float
lng: float
stage: str | None
site_status: str | None = None
ready_dt: dt.date | None = None
flats_total: int | None
flats_sold: int | None
sold_pct: float | None
@ -252,7 +254,8 @@ class TopLayoutRow(BaseModel):
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
avg_area_m2: float
supply_units_in_radius: int
sold_pct_of_supply: float | None # NULL если supply=0
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
class LayoutTzMixRow(BaseModel):

View file

@ -345,8 +345,13 @@ def get_best_layouts(
supply_count = supply_map.get((room_bucket, ab), 0)
sold_pct: float | None = None
is_oversold = False
if supply_count > 0:
sold_pct = round(sum_deals / supply_count * 100.0, 1)
sold_pct_raw = sum_deals / supply_count * 100.0
is_oversold = sold_pct_raw > 100.0
# Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы.
# Значения >100% артефакт окна, не реальная «распроданность».
sold_pct = round(min(sold_pct_raw, 100.0), 1)
# data window
if r["window_start"] is not None:
@ -378,6 +383,7 @@ def get_best_layouts(
"avg_area_m2": avg_area,
"supply_units_in_radius": supply_count,
"sold_pct_of_supply": sold_pct,
"is_oversold": is_oversold,
}
)
@ -403,6 +409,7 @@ def get_best_layouts(
avg_area_m2=round(row["avg_area_m2"], 1),
supply_units_in_radius=row["supply_units_in_radius"],
sold_pct_of_supply=row["sold_pct_of_supply"],
is_oversold=row["is_oversold"],
)
)

View file

@ -356,6 +356,64 @@ def test_target_total_flats_fills_abs_units() -> None:
app.dependency_overrides.clear()
def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
"""raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
id_rows = [_obj_id_row(1)]
# sum_deals=199, supply=100 → raw = 199% (несопоставимые окна)
vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 100)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
latest_snap=dt.date.today(),
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
row = top[0]
assert row["sold_pct_of_supply"] == 100.0, "sold_pct_of_supply должен быть clamped до 100"
assert row["is_oversold"] is True, "is_oversold должен быть True когда raw > 100"
finally:
app.dependency_overrides.clear()
def test_sold_pct_below_100_is_not_oversold() -> None:
"""raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
id_rows = [_obj_id_row(1)]
# sum_deals=50, supply=100 → raw = 50%
vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])]
supply_rows = [_supply_row("1", "40-60", 100)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
latest_snap=dt.date.today(),
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
row = top[0]
assert row["sold_pct_of_supply"] == pytest.approx(50.0)
assert row["is_oversold"] is False
finally:
app.dependency_overrides.clear()
def test_filter_competitor_obj_ids_applied() -> None:
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]

View file

@ -172,9 +172,35 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
fontVariantNumeric: "tabular-nums",
}}
>
{row.sold_pct_of_supply != null
? `${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`
: "—"}
{row.sold_pct_of_supply != null ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
{row.is_oversold && (
<span
style={{
background: "var(--warn-soft)",
color: "var(--warn)",
fontSize: 10,
fontWeight: 600,
padding: "1px 5px",
borderRadius: 4,
whiteSpace: "nowrap",
}}
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
>
&gt;100%
</span>
)}
</span>
) : (
"—"
)}
</td>
</tr>
))}

View file

@ -29,7 +29,8 @@ export interface TopLayoutRow {
avg_price_per_m2_rub: number | null;
avg_area_m2: number;
supply_units_in_radius: number;
sold_pct_of_supply: number | null;
sold_pct_of_supply: number | null; // clamped at 100.0
is_oversold: boolean; // raw ratio was >100% (incompatible time windows)
}
export interface LayoutTzMixRow {