fix(financial): парковка price/cost по классу (econom наземная, business Excel)
Финмодель брала ПЛОСКИЕ parking-константы для ВСЕХ классов: price 1.9М/cost 1.8М (Excel «Авангардная 13» premium — подземная, near break-even). Но норма парковки уже class-dependent (econom 0.8/comfort 1.0/business 1.5 м/м на квартиру). Econom (дешёвое жильё) заряжался 1.8М/м/м ПОДЗЕМНОЙ застройки → огромный break-even капитал, раздувающий пиковый долг/проценты. По РФ econom = наземная дешёвая парковка. Фикс: _PARKING_PRICE_PER_SPOT / _PARKING_COST_PER_SPOT → dict[HousingClass]: - econom 800k/350k (наземная), comfort 1.3М/1.0М (частично подземная), business 1.9М/1.8М (подземная, = Excel, unchanged). - маржа/м/м: econom +450k, comfort +300k, business +100k. - НОРМА парковки (_PARKING_PER_APARTMENT) НЕ тронута (возможно ПЗЗ-норматив). - Константы помечены как TUNABLE ASSUMPTIONS (РФ-рынок), правятся в одном месте (паттерн _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО (честно): парковка — ВТОРИЧНЫЙ драйвер. Улучшает econom в основном через снижение peak-debt/процентов, НЕ переворачивает знак. ДОМИНИРУЮЩИЙ рычаг — себестоимость СМР жилья к выручке (econom 72k на GFA vs выручка на sellable = 87%) — отдельное продуктовое решение (не трогаю без согласования). Инвариант Σ cashflow == net_profit сохранён (меняются только величины revenue_parking/construction_parking в том же каскаде). Тесты: +6 (по классам, маржа, инвариант econom+parking) + 3 существующих исправлены (хардкод comfort 1.9/1.8 → 1.3/1.0). tests/services/generative 90 passed. ruff+mypy(strict) чисто. Схема не менялась. Хозяйственное: untrack + gitignore backend/.coverage (артефакт блокировал checkout). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a45dfd4ee3
commit
4eeda46cfa
5 changed files with 126 additions and 16 deletions
Binary file not shown.
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.coverage
|
||||
|
|
@ -87,7 +87,8 @@ The housing **sale price** can be calibrated to real market data: callers pass
|
|||
reference at the API layer. When ``None`` we fall back to the per-class norm and
|
||||
flag the source honestly (``price_source="class_norm"``) so the UI/PDF never
|
||||
passes a class norm off as a market figure. Construction cost (СМР) is *not*
|
||||
calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms.
|
||||
calibrated in PR-2 — it stays a per-class norm. Parking price/cost are also
|
||||
per-class norms (см. ``_PARKING_PRICE_PER_SPOT`` / ``_PARKING_COST_PER_SPOT``).
|
||||
|
||||
FINANCING — OVERLAY (PR-5)
|
||||
--------------------------
|
||||
|
|
@ -148,10 +149,37 @@ _CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
|
|||
"business": 120_000.0,
|
||||
}
|
||||
|
||||
# ── Паркинг (Excel-эталон девелоперской модели) ───────────────────────────────
|
||||
# Цена продажи / себестоимость одного машиноместа, руб.
|
||||
_PARKING_PRICE_PER_SPOT: float = 1_900_000.0 # Excel: цена продажи м/м
|
||||
_PARKING_COST_PER_SPOT: float = 1_800_000.0 # Excel: себестоимость м/м
|
||||
# ── Паркинг — ЦЕНА ПРОДАЖИ / СЕБЕСТОИМОСТЬ м/м по классу, руб ──────────────────
|
||||
# TUNABLE ASSUMPTIONS (РФ-рынок паркинга). Раньше эти константы были СКАЛЯРАМИ,
|
||||
# взятыми из Excel-эталона «Авангардная 13» (premium, ПОДЗЕМНЫЙ паркинг, near
|
||||
# break-even: 1.9М продажа / 1.8М себестоимость). Применять их ПЛОСКО ко всем
|
||||
# классам некорректно: эконом-жильё в РФ строит ДЕШЁВЫЙ НАЗЕМНЫЙ паркинг, а не
|
||||
# подземный — плоская подземная себестоимость 1.8М × 0.8 м/м на квартиру раздувала
|
||||
# near-break-even капитал → пиковый долг → проценты по финансированию (прод: Ж-5,
|
||||
# 212 м/м × 1.8М = 382М break-even капитал → ~120М процентов, душивших
|
||||
# net_after_financing).
|
||||
#
|
||||
# Поэтому цена/себестоимость теперь КЛАССОЗАВИСИМЫ (зеркалит паттерн
|
||||
# _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО: НОРМА паркинга
|
||||
# (_PARKING_PER_APARTMENT в teap.py: econom 0.8 / comfort 1.0 / business 1.5) НЕ
|
||||
# трогается — она может отражать нормы ПЗЗ (out of scope).
|
||||
#
|
||||
# business 1.9 / 1.8 — ПОДЗЕМНЫЙ, matches Excel «Авангардная 13» (без изменений).
|
||||
# econom 0.8 / 0.35 — НАЗЕМНЫЙ (surface): дешёвый СМР ~350k, продажа ~800k.
|
||||
# comfort 1.3 / 1.0 — ЧАСТИЧНО подземный (интерполяция econom↔business).
|
||||
#
|
||||
# Маржа на м/м: econom +450k, comfort +300k, business +100k (цена > себестоимости
|
||||
# во всех классах — паркинг не убыточен ни в одном классе).
|
||||
_PARKING_PRICE_PER_SPOT: dict[HousingClass, float] = {
|
||||
"econom": 800_000.0, # наземный паркинг — продажа
|
||||
"comfort": 1_300_000.0, # частично подземный — продажа
|
||||
"business": 1_900_000.0, # подземный (Excel «Авангардная 13») — продажа
|
||||
}
|
||||
_PARKING_COST_PER_SPOT: dict[HousingClass, float] = {
|
||||
"econom": 350_000.0, # наземный паркинг — себестоимость
|
||||
"comfort": 1_000_000.0, # частично подземный — себестоимость
|
||||
"business": 1_800_000.0, # подземный (Excel «Авангардная 13») — себестоимость
|
||||
}
|
||||
|
||||
# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
|
||||
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
|
||||
|
|
@ -521,15 +549,19 @@ def compute_financial(
|
|||
price_is_calibrated = False
|
||||
resolved_price_source = "class_norm"
|
||||
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
|
||||
# Паркинг — классозависимы цена/себестоимость (econom наземный, business
|
||||
# подземный); НОРМА м/м на квартиру задаётся в teap (не здесь).
|
||||
parking_price = _PARKING_PRICE_PER_SPOT[housing_class]
|
||||
parking_cost = _PARKING_COST_PER_SPOT[housing_class]
|
||||
|
||||
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
|
||||
revenue_residential = teap.residential_area_sqm * sale_price
|
||||
revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT
|
||||
revenue_parking = teap.parking_spaces * parking_price
|
||||
revenue = revenue_residential + revenue_parking
|
||||
|
||||
# ── Затраты (каскад) ───────────────────────────────────────────────────────
|
||||
construction_resid = teap.total_floor_area_sqm * construction_cost
|
||||
construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT
|
||||
construction_parking = teap.parking_spaces * parking_cost
|
||||
construction = construction_resid + construction_parking
|
||||
pir = construction * _PIR_PCT_OF_CONSTRUCTION
|
||||
networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
|
||||
|
|
|
|||
|
|
@ -134,6 +134,18 @@ def test_invariant_cashflow_sum_equals_net_profit_loss() -> None:
|
|||
assert abs(sum(cf) - model.net_profit_rub) < 1.0
|
||||
|
||||
|
||||
def test_invariant_cashflow_sum_equals_net_profit_econom_parking() -> None:
|
||||
# Классозависимый паркинг (econom: цена 800k / себестоимость 350k) кормит тот же
|
||||
# каскад → инвариант Σ cashflow == net_profit держится (магнитуды паркинга
|
||||
# изменились, но cascade-структура та же).
|
||||
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||
model = financial.compute_financial(
|
||||
teap=t, housing_class="econom", land_cost_rub=150_000_000.0, development_type="mid_rise"
|
||||
)
|
||||
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
|
||||
assert abs(sum(cf) - model.net_profit_rub) < 1.0
|
||||
|
||||
|
||||
def test_invariant_holds_for_all_development_types() -> None:
|
||||
t = _teap(residential=8_000.0, gfa=9_000.0, parking=40)
|
||||
months = {"spot": 24, "mid_rise": 36, "high_rise": 48}
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def test_financial_revenue_includes_parking() -> None:
|
|||
model = financial.compute_financial(
|
||||
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
|
||||
)
|
||||
# revenue = жильё (1000 * 145_000) + паркинг (10 * 1_900_000).
|
||||
# revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000).
|
||||
assert model.revenue_residential_rub == 1000.0 * 145_000.0
|
||||
assert model.revenue_parking_rub == 10 * 1_900_000.0
|
||||
assert model.revenue_parking_rub == 10 * 1_300_000.0
|
||||
assert model.revenue_rub == model.revenue_residential_rub + model.revenue_parking_rub
|
||||
|
||||
|
||||
|
|
@ -94,8 +94,8 @@ def test_financial_cost_cascade_includes_all_lines() -> None:
|
|||
model = financial.compute_financial(
|
||||
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
|
||||
)
|
||||
# СМР = GFA*СМР + паркинг*себест.
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0
|
||||
# СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000).
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
|
||||
# Каждая статья каскада > 0 при ненулевых вводных.
|
||||
assert model.pir_rub > 0
|
||||
assert model.networks_rub > 0
|
||||
|
|
@ -226,7 +226,8 @@ def test_financial_market_price_overrides_class_norm() -> None:
|
|||
|
||||
|
||||
def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None:
|
||||
# Калибруем ТОЛЬКО цену жилья: паркинг (1.9М) и себестоимость СМР (88_000) — норма.
|
||||
# Калибруем ТОЛЬКО цену жилья: паркинг (класс comfort) и себестоимость СМР
|
||||
# (88_000) — норма, не зависят от market_price_per_sqm.
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(
|
||||
teap=t,
|
||||
|
|
@ -235,10 +236,10 @@ def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None:
|
|||
market_price_per_sqm=200_000.0,
|
||||
price_source="district_reference",
|
||||
)
|
||||
# Паркинг — норматив (10 * 1_900_000), не зависит от рыночной цены жилья.
|
||||
assert model.revenue_parking_rub == 10 * 1_900_000.0
|
||||
# СМР — норматив класса comfort (GFA*88_000 + паркинг*1_800_000).
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0
|
||||
# Паркинг — норматив класса comfort (10 * 1_300_000), не зависит от цены жилья.
|
||||
assert model.revenue_parking_rub == 10 * 1_300_000.0
|
||||
# СМР — норматив класса comfort (GFA*88_000 + паркинг*1_000_000).
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
|
||||
|
||||
|
||||
def test_financial_none_market_price_forces_class_norm_source() -> None:
|
||||
|
|
@ -255,3 +256,67 @@ def test_financial_none_market_price_forces_class_norm_source() -> None:
|
|||
assert model.price_is_calibrated is False
|
||||
assert model.price_source == "class_norm"
|
||||
assert model.price_per_sqm_used == 145_000.0
|
||||
|
||||
|
||||
# ── Классозависимая экономика паркинга (econom наземный / business подземный) ────
|
||||
|
||||
|
||||
def test_financial_parking_econom_uses_surface_price_and_cost() -> None:
|
||||
# Эконом — наземный паркинг: цена 800k, себестоимость 350k за м/м.
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None)
|
||||
assert model.revenue_parking_rub == 10 * 800_000.0
|
||||
# СМР = GFA*72_000 (econom) + паркинг наземный 10 * 350_000.
|
||||
assert model.construction_rub == 1300.0 * 72_000.0 + 10 * 350_000.0
|
||||
|
||||
|
||||
def test_financial_parking_comfort_uses_mixed_price_and_cost() -> None:
|
||||
# Комфорт — частично подземный: цена 1.3М, себестоимость 1.0М за м/м.
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
|
||||
assert model.revenue_parking_rub == 10 * 1_300_000.0
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
|
||||
|
||||
|
||||
def test_financial_parking_business_matches_excel_avangardnaya13() -> None:
|
||||
# РЕГРЕСС-ПИН: бизнес-паркинг подземный — без изменений vs Excel «Авангардная 13»
|
||||
# (продажа 1.9М / себестоимость 1.8М за м/м). Любой дрейф этих чисел — провал.
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
|
||||
assert model.revenue_parking_rub == 10 * 1_900_000.0
|
||||
assert model.construction_rub == 1300.0 * 120_000.0 + 10 * 1_800_000.0
|
||||
# Прямая сверка с константами-словарями (теперь dict, не скаляр).
|
||||
assert financial._PARKING_PRICE_PER_SPOT["business"] == 1_900_000.0
|
||||
assert financial._PARKING_COST_PER_SPOT["business"] == 1_800_000.0
|
||||
|
||||
|
||||
def test_financial_parking_margin_positive_for_all_classes() -> None:
|
||||
# Цена > себестоимости во всех классах → паркинг не убыточен ни в одном классе.
|
||||
for hc in ("econom", "comfort", "business"):
|
||||
price = financial._PARKING_PRICE_PER_SPOT[hc] # type: ignore[index]
|
||||
cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index]
|
||||
assert price > cost, hc
|
||||
# Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k.
|
||||
assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[
|
||||
"econom"
|
||||
] == 450_000.0
|
||||
assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[
|
||||
"comfort"
|
||||
] == 300_000.0
|
||||
assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[
|
||||
"business"
|
||||
] == 100_000.0
|
||||
|
||||
|
||||
def test_financial_econom_parking_cheaper_than_business() -> None:
|
||||
# Тот же участок: econom-паркинг (наземный) даёт МЕНЬШЕ паркинговой выручки и
|
||||
# МЕНЬШЕ паркинговой себестоимости, чем business (подземный) — проверка, что
|
||||
# классозависимость реально применяется в каскаде.
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
econ = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None)
|
||||
biz = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
|
||||
assert econ.revenue_parking_rub < biz.revenue_parking_rub
|
||||
# Паркинговая часть СМР: econom 350k/м < business 1.8М/м.
|
||||
econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0
|
||||
biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0
|
||||
assert econ_parking_smr < biz_parking_smr
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue