fix(sf-05): clamp sold_pct_of_supply на 100% + is_oversold flag #274
5 changed files with 101 additions and 6 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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%"
|
||||
>
|
||||
>100%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue