fmt
Some checks failed
CI / Check (push) Failing after 4m14s
Build and publish Docker image / build-and-push (push) Successful in 4m52s

This commit is contained in:
Andras Schmelczer 2026-07-14 14:04:44 +01:00
parent cf348c3ea4
commit d1faad314b
14 changed files with 55 additions and 35 deletions

View file

@ -18,7 +18,6 @@ from __future__ import annotations
import html
import json
import urllib.parse
from pathlib import Path
ROOT = Path(".")

View file

@ -74,7 +74,7 @@ def twin_kit(f: dict) -> str:
]
captions = [
f"{pn} vs {wn}",
f"Same station. Same schools.",
"Same station. Same schools.",
f"{money} cheaper",
f"Same {typ}, ~{s['build_year']}",
f"{gap} less per m²",
@ -135,7 +135,7 @@ def twin_kit(f: dict) -> str:
"",
f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~4560s long + a 9:16 Short cut",
"",
f"## 🎬 Map URL to record (open this, hit record)",
"## 🎬 Map URL to record (open this, hit record)",
f"`{url}`",
"*(filters are pre-applied so the value is on screen immediately)*",
"",

View file

@ -139,9 +139,6 @@ def curate(tw: pl.DataFrame) -> list[dict]:
def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]:
named = idx.with_columns(
pl.col("Sector").str.extract(r"^([A-Z]+[0-9][A-Z0-9]?)", 1).alias("outward")
)
best = facts["best_value_sector"]
dear = facts["dearest_sector"]
return [

View file

@ -315,7 +315,8 @@
"ax1.plot(yr, piv[\"existing\"].to_numpy(), marker=\"o\", label=\"Existing houses\", color=\"#0f766e\")\n",
"ax1.plot(yr, piv[\"new\"].to_numpy(), marker=\"s\", label=\"New-build houses\", color=\"#d97706\")\n",
"ax1.set_title(\"Median £/sqm by sale year\")\n",
"ax1.set_xlabel(\"Year\"); ax1.set_ylabel(\"£/sqm\")\n",
"ax1.set_xlabel(\"Year\")\n",
"ax1.set_ylabel(\"£/sqm\")\n",
"ax1.yaxis.set_major_formatter(mticker.StrMethodFormatter(\"£{x:,.0f}\"))\n",
"ax1.legend()\n",
"\n",
@ -323,9 +324,11 @@
"ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n",
"ax2.axhline(0, color=\"black\", lw=0.8)\n",
"ax2.set_title(\"New-build premium (median £/sqm, new vs existing)\")\n",
"ax2.set_xlabel(\"Year\"); ax2.set_ylabel(\"Premium %\")\n",
"ax2.set_xlabel(\"Year\")\n",
"ax2.set_ylabel(\"Premium %\")\n",
"ax2.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"plt.tight_layout(); plt.show()\n",
"plt.tight_layout()\n",
"plt.show()\n",
"\n",
"piv.select(\"year\", \"existing\", \"new\", \"premium_pct\", \"n_new\", \"n_existing\")"
]
@ -426,7 +429,8 @@
"for y, v, nnew in zip(range(len(labels)), vals, area_prem[\"n_new\"].to_list()):\n",
" ax.text(v + (0.4 if v >= 0 else -0.4), y, f\"n={nnew}\", va=\"center\",\n",
" ha=\"left\" if v >= 0 else \"right\", fontsize=8, color=\"#555\")\n",
"plt.tight_layout(); plt.show()\n",
"plt.tight_layout()\n",
"plt.show()\n",
"area_prem"
]
},
@ -523,10 +527,12 @@
"ax.axvline(np.median(old_cagr), color=\"#0f766e\", ls=\"--\", lw=1.2)\n",
"ax.axvline(np.median(new_cagr), color=\"#d97706\", ls=\"--\", lw=1.2)\n",
"ax.set_title(\"Distribution of annualised resale growth (CAGR)\")\n",
"ax.set_xlabel(\"CAGR % per year\"); ax.set_ylabel(\"density\")\n",
"ax.set_xlabel(\"CAGR % per year\")\n",
"ax.set_ylabel(\"density\")\n",
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.legend()\n",
"plt.tight_layout(); plt.show()"
"plt.tight_layout()\n",
"plt.show()"
]
},
{
@ -603,16 +609,19 @@
"\n",
"area_cagr = cagr_by(\"area\")\n",
"labels = area_cagr[\"area\"].to_list()\n",
"x = np.arange(len(labels)); w = 0.4\n",
"x = np.arange(len(labels))\n",
"w = 0.4\n",
"fig, ax = plt.subplots(figsize=(10, 4.5))\n",
"ax.bar(x - w/2, area_cagr[\"existing_cagr\"].to_numpy(), w, label=\"Existing\", color=\"#0f766e\")\n",
"ax.bar(x + w/2, area_cagr[\"newbuild_cagr\"].to_numpy(), w, label=\"New-build\", color=\"#d97706\")\n",
"ax.set_xticks(x); ax.set_xticklabels(labels)\n",
"ax.set_xticks(x)\n",
"ax.set_xticklabels(labels)\n",
"ax.set_title(\"Median resale CAGR by postal area\")\n",
"ax.set_ylabel(\"CAGR % per year\")\n",
"ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.legend()\n",
"plt.tight_layout(); plt.show()\n",
"plt.tight_layout()\n",
"plt.show()\n",
"area_cagr"
]
},
@ -699,22 +708,25 @@
" area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n",
")\n",
"fig, ax = plt.subplots(figsize=(8, 6))\n",
"xs = combined[\"premium_pct\"].to_numpy(); ys = combined[\"cagr_gap\"].to_numpy()\n",
"xs = combined[\"premium_pct\"].to_numpy()\n",
"ys = combined[\"cagr_gap\"].to_numpy()\n",
"ax.scatter(xs, ys, s=70, color=\"#d97706\", zorder=3)\n",
"for a, xv, yv in zip(combined[\"area\"].to_list(), xs, ys):\n",
" ax.annotate(a, (xv, yv), textcoords=\"offset points\", xytext=(6, 4), fontsize=10)\n",
"ax.axhline(0, color=\"black\", lw=0.8); ax.axvline(0, color=\"black\", lw=0.8)\n",
"ax.axhline(0, color=\"black\", lw=0.8)\n",
"ax.axvline(0, color=\"black\", lw=0.8)\n",
"ax.set_xlabel(\"New-build £/sqm premium at purchase\")\n",
"ax.set_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n",
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.set_title(\"Premium paid vs appreciation gap, by London postal area\")\n",
"plt.tight_layout(); plt.show()\n",
"plt.tight_layout()\n",
"plt.show()\n",
"\n",
"# Headline numbers for the writeup.\n",
"hl = summary.to_dicts()\n",
"new_row = next(r for r in hl if r[\"is_newbuild_prop\"])\n",
"old_row = next(r for r in hl if not r[\"is_newbuild_prop\"])\n",
"print(f\"London terraced/detached, repeat sales:\")\n",
"print(\"London terraced/detached, repeat sales:\")\n",
"print(f\" existing median CAGR: {old_row['median_cagr_pct']}%/yr (n={old_row['n']:,})\")\n",
"print(f\" new-build median CAGR: {new_row['median_cagr_pct']}%/yr (n={new_row['n']:,})\")\n",
"print(f\" appreciation gap: {round(new_row['median_cagr_pct']-old_row['median_cagr_pct'],2)} pp/yr\")\n",