CFDL

CRE office acquisition (institutional lease-by-lease DCF)

Outputs below are real: the notebook runs against the cre pack's benchmark model, which CFDL validates against an independent reference. To run it yourself, see the Python SDK guide.

A two-minute agent-driven walkthrough: an AI agent executes this notebook cell by cell, every output computing live in the take. Captions carry the narration.

Open In Colab

A two-tenant office acquisition modeled lease-by-lease: free rent, anniversary escalations, expense recoveries over stops, TI/LC, probability-weighted rollover, and an exit on forward NOI.

This notebook uses one of the benchmark models, which CFDL validates against an independent reference to the penny.

# On Colab, install the SDK and fetch the models this notebook reads.
# Inside a checkout both are already present and this cell does nothing.
import subprocess, sys
from pathlib import Path

REPO = "https://github.com/cfdl-dev/cfdl"


def repo_root() -> Path:
    """The checkout holding benchmarks/ and packs/, cloning it if need be.

    Searching a bounded set of ancestors means a plain `python` run outside a
    checkout fails with an explanation rather than walking to the filesystem
    root. On a hosted runtime there is no checkout to find, so fetch one.
    """
    here = Path.cwd().resolve()
    for candidate in (here, *here.parents):
        if (candidate / "Cargo.toml").exists() and (candidate / "packs").is_dir():
            return candidate

    if "google.colab" not in sys.modules:
        raise RuntimeError(
            f"No CFDL checkout found above {here}. This notebook reads a model "
            f"from benchmarks/ and pack definitions from packs/, so run it "
            f"inside a clone of {REPO}."
        )

    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "cfdl-sdk[viz]"], check=True)

    # Packs and benchmark models track the engine, so take the checkout at the
    # tag matching the wheel pip just resolved. `main` runs ahead of the last
    # release and its packs may use metric ops the released engine rejects.
    import importlib
    from importlib.metadata import PackageNotFoundError, version

    importlib.invalidate_caches()
    try:
        tag = f"v{version('cfdl-sdk')}"
    except PackageNotFoundError:
        tag = None

    clone = ["git", "clone", "--depth", "1", "-q", REPO]
    target = Path("/content/cfdl")
    if not target.exists():
        pinned = tag is not None and not subprocess.run(
            clone + ["--branch", tag, str(target)]
        ).returncode
        if not pinned:
            # A dev or pre-release wheel has no matching tag; main is the best
            # available, and the notebook may fail if the two have diverged.
            print(f"warning: no {tag} tag for this SDK build; falling back to main.")
            subprocess.run(clone + [str(target)], check=True)
    return target


ROOT = repo_root()
PACKS = ROOT / "packs"

import cfdl_sdk

Compile

Compile the model directory to IR.

model_dir = ROOT / "benchmarks/cre/office_two_tenant"
model = cfdl_sdk.compile(model_dir, packs_dir=PACKS)
print("streams:", len(model.ir["streams"]))
streams: 17

Run

Run with the benchmark's configuration and apply the cre pack's domain metrics.

results = model.run(
    config=str(model_dir / "run.json"),
    pack="cre",
)
print("status:", results.status, "| warnings:", len(results.warnings))
status: ok | warnings: 0

Cash flows

The engine returns per-period signed cash flows; cashflows() gives a wide DataFrame indexed by period.

cf = results.cashflows()
print('shape:', cf.shape)
cf.head()
shape: (120, 27)
         account.asset.tower.balance  domain.cre.debt_service  domain.cre.dscr  domain.cre.egi  \
period                                                                                           
2026-01                 5.990655e+06             36845.249537        -0.719224         -1500.0   
2026-02                 5.981267e+06             36845.249537        -0.719224         -1500.0   
2026-03                 5.971836e+06             36845.249537        -0.719224         -1500.0   
2026-04                 5.962361e+06             36845.249537         0.366397         38500.0   
2026-05                 5.952843e+06             36845.249537         0.366397         38500.0   

         domain.cre.leasing_costs  domain.cre.noi  domain.cre.opex_total  domain.cre.pgr  \
period                                                                                     
2026-01                  200000.0        -26500.0               -25000.0         40000.0   
2026-02                       0.0        -26500.0               -25000.0         40000.0   
2026-03                       0.0        -26500.0               -25000.0         40000.0   
2026-04                       0.0         13500.0               -25000.0         40000.0   
2026-05                       0.0         13500.0               -25000.0         40000.0   

         entity.asset.tower.net_cash_flow  model.net_cash_flow  ...  \
period                                                          ...   
2026-01                    -263345.249537       -263345.249537  ...   
2026-02                     -63345.249537        -63345.249537  ...   
2026-03                     -63345.249537        -63345.249537  ...   
2026-04                     -23345.249537        -23345.249537  ...   
2026-05                     -23345.249537        -23345.249537  ...   

         stream.cre.rollover.ti_lc.tenant_a  stream.cre.unit.abatement.tenant_a  \
period                                                                            
2026-01                                 0.0                            -40000.0   
2026-02                                 0.0                            -40000.0   
2026-03                                 0.0                            -40000.0   
2026-04                                 0.0                                 0.0   
2026-05                                 0.0                                 0.0   

         stream.cre.unit.abatement.tenant_b  stream.cre.unit.base_rent.tenant_a  \
period                                                                            
2026-01                                 0.0                             40000.0   
2026-02                                 0.0                             40000.0   
2026-03                                 0.0                             40000.0   
2026-04                                 0.0                             40000.0   
2026-05                                 0.0                             40000.0   

         stream.cre.unit.base_rent.tenant_b  stream.cre.unit.recoveries.tenant_a  \
period                                                                             
2026-01                                 0.0                                  0.0   
2026-02                                 0.0                                  0.0   
2026-03                                 0.0                                  0.0   
2026-04                                 0.0                                  0.0   
2026-05                                 0.0                                  0.0   

         stream.cre.unit.recoveries.tenant_b  stream.cre.unit.ti_lc.tenant_a  \
period                                                                         
2026-01                                  0.0                       -200000.0   
2026-02                                  0.0                             0.0   
2026-03                                  0.0                             0.0   
2026-04                                  0.0                             0.0   
2026-05                                  0.0                             0.0   

         stream.cre.unit.ti_lc.tenant_b  stream.cre.vacancy.loss  
period                                                            
2026-01                             0.0                  -1500.0  
2026-02                             0.0                  -1500.0  
2026-03                             0.0                  -1500.0  
2026-04                             0.0                  -1500.0  
2026-05                             0.0                  -1500.0  

[5 rows x 27 columns]
# Requires the [viz] extra (pip install cfdl-sdk[viz]).
results.plot.cumulative()
<Axes: xlabel='period', ylabel='cumulative amount'>

Chart produced by the preceding cell

Metrics

Core metrics (NPV/IRR/MOIC/...) plus the pack's domain metrics, with their source labeled.

results.metrics_frame()
                                     metric         value currency      source
0                   domain.cre.debt_service  4.421430e+06      USD  domain:cre
1                           domain.cre.dscr  1.067287e+00           domain:cre
2                  domain.cre.leasing_costs  5.250000e+05      USD  domain:cre
3                            domain.cre.noi  4.718934e+06      USD  domain:cre
4                  entity.asset.tower.total  3.009647e+06      USD        core
5                                 model.irr  2.907830e-01                 core
6                                model.moic  3.234175e+00                 core
7                                 model.npv  1.424274e+06      USD        core
8                     model.payback_periods  5.300000e+01                 core
9                       model.payback_years  4.500000e+00                 core
10                              model.total  3.009647e+06      USD        core
11                          model.wal_years  8.508512e+00                 core
12                 run.annual_discount_rate  7.250000e-02                 core
13                     run.periods_per_year  1.200000e+01                 core
14           stream.cre.debt.interest.total -2.930792e+06      USD        core
15          stream.cre.debt.principal.total -1.490638e+06      USD        core
16           stream.cre.debt.proceeds.total  0.000000e+00      USD        core
17           stream.cre.exit.proceeds.total  3.303207e+06      USD        core
18      stream.cre.exit.selling_costs.total -6.606414e+04      USD        core
19               stream.cre.opex.line.total -3.361015e+06      USD        core
20  stream.cre.rollover.rent.tenant_a.total  2.782460e+06      USD        core
21  stream.cre.rollover.ti_lc.tenant_a.t... -1.750000e+05      USD        core
22  stream.cre.unit.abatement.tenant_a.t... -1.200000e+05      USD        core
23  stream.cre.unit.abatement.tenant_b.t...  0.000000e+00      USD        core
24  stream.cre.unit.base_rent.tenant_a.t...  2.548385e+06      USD        core
25  stream.cre.unit.base_rent.tenant_b.t...  2.717075e+06      USD        core
26  stream.cre.unit.recoveries.tenant_a....  3.075942e+04      USD        core
27  stream.cre.unit.recoveries.tenant_b....  3.012687e+05      USD        core
28     stream.cre.unit.ti_lc.tenant_a.total -2.000000e+05      USD        core
29     stream.cre.unit.ti_lc.tenant_b.total -1.500000e+05      USD        core
30            stream.cre.vacancy.loss.total -1.800000e+05      USD        core

What-if

Inspect the derived forward-NOI exit value and the DSCR domain metric.

mf = results.metrics_frame()
mf[mf["metric"].str.contains("dscr|noi|exit", case=False)]
                                 metric         value currency      source
1                       domain.cre.dscr  1.067287e+00           domain:cre
3                        domain.cre.noi  4.718934e+06      USD  domain:cre
17       stream.cre.exit.proceeds.total  3.303207e+06      USD        core
18  stream.cre.exit.selling_costs.total -6.606414e+04      USD        core

Extended analysis — the DataFrame is the API

Everything below is ordinary pandas over cashflows(). The engine guarantees the numbers (this case is asserted against an independent reference in CI); the analysis on top is yours.

# Annual rollup with a computed coverage column.
annual = cf[["domain.cre.egi", "domain.cre.noi", "domain.cre.debt_service"]].groupby(cf.index.year).sum()
annual["dscr"] = annual["domain.cre.noi"] / annual["domain.cre.debt_service"]
annual.round(2)
        domain.cre.egi  domain.cre.noi  domain.cre.debt_service  dscr
period                                                               
2026         540000.00       240000.00                442142.99  0.54
2027         881025.00       573525.00                442142.99  1.30
2028         910322.62       595135.12                442142.99  1.35
2029         940426.85       617359.66                442142.99  1.40
2030         971360.07       640216.20                442142.99  1.45
2031         920924.75       581502.28                442142.99  1.32
2032         991457.87       643549.84                442142.99  1.46
2033         780329.82       423724.09                442142.99  0.96
2034         563330.76       197809.89                442142.99  0.45
2035         580770.69       206111.80                442142.99  0.47

Year one is lease-up — a 0.5x coverage year a lifetime DSCR of 1.07 would have hidden entirely. Per-period series are what make covenant work possible.

# Covenant screen and trailing-12 NOI.
tight = cf[cf["domain.cre.dscr"] < 1.0]
print(f"months below 1.00x DSCR: {len(tight)} of {len(cf)} (last: {tight.index.max()})")
cf["domain.cre.noi"].rolling(12).sum().plot(title="Trailing-12 NOI")
months below 1.00x DSCR: 36 of 120 (last: 2035-12)
<Axes: title={'center': 'Trailing-12 NOI'}, xlabel='period'>

Chart produced by the preceding cell

# Tenant-level revenue composition: the lease-by-lease grain survives into results.
rev_cols = [c for c in cf.columns if ".base_rent." in c or ".recoveries." in c or ".rollover.rent." in c]
rev = cf[rev_cols].groupby(cf.index.year).sum()
rev.rename(columns=lambda c: c.replace("stream.cre.", "")).plot.area(title="Revenue composition by year")
<Axes: title={'center': 'Revenue composition by year'}, xlabel='period'>

Chart produced by the preceding cell

# Value sensitivity: re-run the deal across a discount grid — four engine runs.
import pandas as pd
npv = pd.Series({
    rate: model.run(config={"deterministic": {"annual_discount_rate": rate}}, pack="cre").metrics()["model.npv"]
    for rate in (0.06, 0.08, 0.10, 0.12)
})
npv.round(0)
0.06    1622142.0
0.08    1316581.0
0.10    1064405.0
0.12     855277.0
dtype: float64