|
| 1 | +# Primary mortgage market survey (interactive) |
| 2 | + |
| 3 | +```js |
| 4 | +const pmms = FileAttachment("data/pmms.csv").csv({typed: true}); |
| 5 | +``` |
| 6 | + |
| 7 | +```js |
| 8 | +const color = Plot.scale({color: {domain: ["30Y FRM", "15Y FRM"]}}); |
| 9 | +const colorLegend = (y) => html`<span style="border-bottom: solid 2px ${color.apply(`${y}Y FRM`)};">${y}-year fixed-rate</span>`; |
| 10 | +``` |
| 11 | + |
| 12 | +```js |
| 13 | +const tidy = pmms.flatMap(({date, pmms30, pmms15}) => [{date, rate: pmms30, type: "30Y FRM"}, {date, rate: pmms15, type: "15Y FRM"}]); |
| 14 | +``` |
| 15 | + |
| 16 | +```js |
| 17 | +function frmCard(y, pmms) { |
| 18 | + const key = `pmms${y}`; |
| 19 | + const pmmsSubset = pmms.filter(d => d.date.getFullYear() === selectedYear); |
| 20 | + const diff1 = pmmsSubset.at(-1)[key] - pmmsSubset.at(-2)[key]; |
| 21 | + const diffY = pmmsSubset.at(-1)[key] - pmmsSubset.at(0)[key]; |
| 22 | + const range = d3.extent(pmmsSubset, (d) => d[key]); |
| 23 | + const stroke = color.apply(`${y}Y FRM`); |
| 24 | + return html.fragment` |
| 25 | + <h2 style="color: ${stroke}">${y}-year fixed-rate in ${selectedYear}</b></h2> |
| 26 | + <h1>${formatPercent(pmmsSubset.at(-1)[key])}</h1> |
| 27 | + <table> |
| 28 | + <tr> |
| 29 | + <td>1-week change</td> |
| 30 | + <td align="right">${formatPercent(diff1, {signDisplay: "always"})}</td> |
| 31 | + <td>${trend(diff1)}</td> |
| 32 | + </tr> |
| 33 | + <tr> |
| 34 | + <td>1-year change</td> |
| 35 | + <td align="right">${formatPercent(diffY, {signDisplay: "always"})}</td> |
| 36 | + <td>${trend(diffY)}</td> |
| 37 | + </tr> |
| 38 | + <tr> |
| 39 | + <td>4-week average</td> |
| 40 | + <td align="right">${formatPercent(d3.mean(pmmsSubset.slice(-4), (d) => d[key]))}</td> |
| 41 | + </tr> |
| 42 | + <tr> |
| 43 | + <td>52-week average</td> |
| 44 | + <td align="right">${formatPercent(d3.mean(pmmsSubset, (d) => d[key]))}</td> |
| 45 | + </tr> |
| 46 | + </table> |
| 47 | + ${resize((width) => |
| 48 | + Plot.plot({ |
| 49 | + width, |
| 50 | + height: 40, |
| 51 | + axis: null, |
| 52 | + x: {inset: 40}, |
| 53 | + marks: [ |
| 54 | + Plot.tickX(pmmsSubset, { |
| 55 | + x: key, |
| 56 | + stroke, |
| 57 | + insetTop: 10, |
| 58 | + insetBottom: 10, |
| 59 | + title: (d) => `${d.date?.toLocaleDateString("en-us")}: ${d[key]}%`, |
| 60 | + tip: {anchor: "bottom"} |
| 61 | + }), |
| 62 | + Plot.tickX(pmmsSubset.slice(-1), {x: key, strokeWidth: 2}), |
| 63 | + Plot.text([`${range[0]}%`], {frameAnchor: "left"}), |
| 64 | + Plot.text([`${range[1]}%`], {frameAnchor: "right"}) |
| 65 | + ] |
| 66 | + }) |
| 67 | + )} |
| 68 | + <span class="small muted">52-week range</span> |
| 69 | + `; |
| 70 | +} |
| 71 | +
|
| 72 | +function formatPercent(value, format) { |
| 73 | + return value == null |
| 74 | + ? "N/A" |
| 75 | + : (value / 100).toLocaleString("en-US", {minimumFractionDigits: 2, style: "percent", ...format}); |
| 76 | +} |
| 77 | +
|
| 78 | +function trend(v) { |
| 79 | + return v >= 0.005 ? html`<span class="green">↗︎</span>` |
| 80 | + : v <= -0.005 ? html`<span class="red">↘︎</span>` |
| 81 | + : "→"; |
| 82 | +} |
| 83 | +
|
| 84 | +``` |
| 85 | +
|
| 86 | +Each week, [Freddie Mac](https://www.freddiemac.com/pmms/about-pmms.html) surveys lenders on rates and points for their ${colorLegend(30)}, ${colorLegend(15)}, and other mortgage products. Data as of ${pmms.at(-1).date?.toLocaleDateString("en-US", {year: "numeric", month: "long", day: "numeric"})}. |
| 87 | +
|
| 88 | +```js |
| 89 | +const selectedYear = view(Inputs.range(d3.extent(pmms, d => d.date.getFullYear()), {label: 'Year:', step: 1, value: 2023})); |
| 90 | +``` |
| 91 | +
|
| 92 | +<style type="text/css"> |
| 93 | +
|
| 94 | +@container (min-width: 560px) { |
| 95 | + .grid-cols-2-3 { |
| 96 | + grid-template-columns: 1fr 1fr; |
| 97 | + } |
| 98 | + .grid-cols-2-3 .grid-colspan-2 { |
| 99 | + grid-column: span 2; |
| 100 | + } |
| 101 | +} |
| 102 | +
|
| 103 | +@container (min-width: 840px) { |
| 104 | + .grid-cols-2-3 { |
| 105 | + grid-template-columns: 1fr 2fr; |
| 106 | + grid-auto-flow: column; |
| 107 | + } |
| 108 | +} |
| 109 | +
|
| 110 | +</style> |
| 111 | +
|
| 112 | +<div class="grid grid-cols-2-3"> |
| 113 | + <div class="card">${frmCard(30, pmms)}</div> |
| 114 | + <div class="card">${frmCard(15, pmms)}</div> |
| 115 | + <div class="card grid-colspan-2 grid-rowspan-2" style="display: flex; flex-direction: column;"> |
| 116 | + <h2>Rates over the year ${selectedYear}</h2> |
| 117 | + <span style="flex-grow: 1;">${resize((width, height) => |
| 118 | + Plot.plot({ |
| 119 | + width, |
| 120 | + height, |
| 121 | + y: {grid: true, label: "rate (%)"}, |
| 122 | + color, |
| 123 | + marks: [ |
| 124 | + Plot.lineY(tidy.filter(d => d.date.getFullYear() === selectedYear), {x: "date", y: "rate", stroke: "type", curve: "step", tip: true, markerEnd: true}) |
| 125 | + ] |
| 126 | + }) |
| 127 | + )}</span> |
| 128 | + </div> |
| 129 | +</div> |
| 130 | +
|
| 131 | +<div class="grid"> |
| 132 | + <div class="card"> |
| 133 | + <h2>Rates over all time (${d3.extent(pmms, (d) => d.date.getUTCFullYear()).join("–")})</h2> |
| 134 | + ${resize((width) => |
| 135 | + Plot.plot({ |
| 136 | + width, |
| 137 | + y: {grid: true, label: "rate (%)"}, |
| 138 | + color, |
| 139 | + marks: [ |
| 140 | + Plot.rectY([{year: selectedYear}], {x1: d => new Date(d.year, 0, 1), x2: d => new Date(d.year+1, 0), y1: 0, y2: d3.max(tidy, d => d.rate), fill: "var(--theme-foreground)", opacity: 0.15}), |
| 141 | + Plot.ruleY([0]), |
| 142 | + Plot.lineY(tidy, {x: "date", y: "rate", stroke: "type", tip: true}) |
| 143 | + ] |
| 144 | + }) |
| 145 | + )} |
| 146 | + </div> |
| 147 | +</div> |
0 commit comments