Skip to main content
ntro.valuations turns a property’s facts and a set of assumptions into a forecast cashflow and the comparable valuation lanes the Analysis UI renders. It has three parts: a pure engine, a typed domain model, and a versioned store. The whole namespace obeys one law:
value = f(facts, assumptions, engine_version)
Outputs are never an input. You store the facts (assets) and the assumptions, record which engine produced a snapshot, and re-derive (or read a cache) on demand. Four data kinds flow through:
KindWhatWhere it lives
FactsThe asset(s): units, income streams, facilitiesledgers.assets
AssumptionsGrowth, occupancy, opex, tax, debt termsanalysis.valuations.assumption_set
DerivedThe forecast cashflow (a cache)analysis.valuations.forecast_snapshot
ActualsRealised book value / cashflow from the GLanalysis.valuations (kind="actuals")

Engine

run_model is deterministic, pure, and entity-agnostic — same inputs, same bytes out. It is the single source of truth; the ui-tenant sensitivity controls call this one engine rather than reimplementing it.
from ntro.valuations import run_model, ENGINE_VERSION

result = run_model(assumptions, assets)   # -> CashflowResult
result.engine_version    # "re-0.1.0"
result.months            # list[MonthRow] — the monthly ladder
result.fiscal_years      # list[FYRow]   — FY rollups
ENGINE_VERSION (re-0.1.0) stamps every snapshot. Bump it when the calc changes; cached forecasts produced by an older version are treated as stale.

Output shape

MonthRow — one row per model month:
FieldMeaning
month_index, year, monthPosition in the model
effective_occupancyOccupancy after the ramp
total_incomeGross rental + parking + EV + ancillary
total_direct_costs, gross_profitAfter direct costs
total_opex, ebitdaNOI (= ebitda)
loan_opening, interest, arrangement_fee, loan_closingFinancing
npbt, corporation_tax, npatLevered, pre / post tax
capexCash capex outflow (lumpy, negative)
free_cash_flowFCFE — after tax, after capex
property_nbvCarrying value (net book value) at month-end
FYRow — fiscal-year rollup: gross_rental_income, ebitda, npat, gross_yield, net_yield, property_value (NBV at the FY-end month).

Domain model

ntro.valuations.models is the typed input surface. All money is Decimal.
SymbolPurpose
AssetA modelled property / unit — income_streams, optional facility, asset_type, provenance
IncomeStreamOne revenue line — kind (rent / parking / ev / …), monthly_amount, grows_with_rent, subject_to_occupancy, optional metered
MeteredIncomeUsage-priced income — capacity, hours_per_day, utilisation, rate_per_unit
FacilityDebt — loan, interest_rate_pa, term_years (0 = interest-only); exposes derived LTV / DSCR
PeriodicOpexA cost that applies only in certain calendar months (e.g. insurance)
RealEstateAssumptionsThe full assumption set — horizon, occupancy ramp, growth, opex, tax, capex cycle, mortgage
CashflowModelA DCF set: assumptions + assets + a cached forecast. headline() → comparable value / yield / ERV
PointValuationA point set (Bank lane): observed value / equivalent_yield / estimated_rental_value / valuation_date. headline() matches CashflowModel.headline() so lanes line up

Store

ntro.valuations.store persists versioned valuation sets. Two set kinds, discriminated by engine_version:
KindSave / loadengine_version
DCF (Forecast / Projected)save_model / load_modelthe engine’s (e.g. re-0.1.0)
Point (Bank)save_point_valuation / load_point_valuation"" (the discriminator)
from ntro.valuations import (
    save_model, load_model,
    save_point_valuation, load_point_valuation,
    list_valuation_sets,
)

# Latest version of every lane for an entity — what the Analysis page reads.
sets = await list_valuation_sets(entity_id=eid, tenant_slug="acme")
# -> [{label, basis, version, engine_version, period, headline}, …]
Each save_* for an (entity, label) writes model_version = max(existing) + 1 — old versions are retained so you can diff Forecast-v2 vs v3, or Bank vs Indicative.

Caching: inputs_hash / forecast_is_stale

A saved DCF set caches its forecast snapshot keyed by inputs_hash — a sha256 of (assumptions, assets, engine_version) via compute_inputs_hash. Before trusting a cached forecast, check forecast_is_stale(row, assumptions, assets): True when the inputs or the engine version moved, and you should re-run_model.

Storage layout

Two schemas, deliberately split:
  • ledgers.assets — the facts (one AssetRow per asset). Lives in the ledgers schema alongside the GL because it is reconciliation-grade truth.
  • analysis.valuations — the forward / analytical model (assumption set, engine version, cached forecast, asset references). The analysis schema is the forward lane, never an accounting ledger. This is the model’s own store — not a subledger you code against directly; go through ntro.valuations.store.
A CashflowModel references the AssetRow ids it was built from, so re-loading reconstructs the exact facts the snapshot was derived from.

Worked example

from decimal import Decimal
from uuid import UUID
from ntro.valuations import (
    Asset, IncomeStream, Facility, RealEstateAssumptions,
    run_model, save_model, load_model,
)

assumptions = RealEstateAssumptions(
    model_years=15, start_year=2026, start_month=1, fiscal_year_end_month=9,
    occupancy_ramp=[Decimal("0.5"), Decimal("0.8"), Decimal("1")],
    rental_growth_pa=Decimal("0.03"), opex_inflation_pa=Decimal("0.02"),
    building_appreciation_pa=Decimal("0.02"), building_value_cost=Decimal("1200000"),
    number_of_assets=10, vat_rate=Decimal("0.2"), corp_tax_rate=Decimal("0.19"),
    average_tenancy_months=Decimal("12"), agent_fee_rate=Decimal("0.1"),
    letup_fee=Decimal("500"), dilapidation=Decimal("0"),
    opex_monthly={"management": Decimal("400")},
    sinking_fund_rate_pa=Decimal("0"), capex_refurb_year=10,
    capex_cost_per_asset=Decimal("8000"),
    mortgage_amount=Decimal("1200000"), arrangement_fee_rate=Decimal("0.01"),
    interest_rate_pa=Decimal("0.06"),
)

assets = [
    Asset(
        name="Flat 1",
        income_streams=[IncomeStream(kind="rent", monthly_amount=Decimal("1500"))],
        facility=Facility(loan=Decimal("120000"), interest_rate_pa=Decimal("0.06")),
    ),
    # … 9 more
]

# Derive
result = run_model(assumptions, assets)
print(result.fiscal_years[0].property_value, result.months[0].free_cash_flow)

# Persist a new version (DCF set) with the forecast cached
version = await save_model(
    entity_id=UUID("…"), task_id=UUID("…"), period="2026-01",
    assumptions=assumptions, assets=assets,
    label="Projected", basis="Forecast",
    forecast=result.model_dump(mode="json"), tenant_slug="acme",
)

# Read it back and re-run (cache-or-recompute is the caller's choice)
model = await load_model(entity_id=UUID("…"), label="Projected", tenant_slug="acme")
fresh = run_model(model.assumptions, model.assets)
The engine is authoritative. The Analysis page’s sensitivity controls send adjusted assumptions to this same run_model — there is no JavaScript reimplementation to drift out of sync.

assets

AssetRow — the per-title facts the engine models.

Subledgers overview

Row base + the ledgers / analysis schema split.

Data capability

Data-plane access used by the store helpers.

Typing

Period and the shared field types.