Bank Lending Standards
Senior Loan Officer Opinion Survey — Federal Reserve
The Fed asks senior loan officers if they’re tightening credit. Four times a year. We chart it.
Data source: Federal Reserve Senior Loan Officer Opinion Survey (SLOOS) —
Table 1 (domestic banks), parsed from Fed HTML tables via the bens-data-lake pipeline.
Net percentage = % reporting tighter standards minus % reporting easier standards.
All-respondent segment only.
See Data Sources for methodology.
Four times a year, the Federal Reserve asks senior loan officers at major US banks whether they’ve been tightening or loosening lending standards. The result is a diffusion index — net percentage tightening — which tells you how many banks changed, not by how much.
Sustained readings above +20% have historically preceded things worth paying attention to. Readings above +40% have historically preceded things you definitely don’t want to be fully invested during.
Code
import { Plot } from "npm:@observablehq/plot"
data = sloos_data.map(d => ({...d, survey_date: new Date(d.survey_date)}))
COLORS = ({
"C&I - Large": "#1d4ed8",
"C&I - Small": "#06b6d4",
"Consumer - Credit Card": "#b45309",
"Consumer - Auto": "#be123c"
})
LOAN_TYPES = ["C&I - Large", "C&I - Small", "Consumer - Credit Card", "Consumer - Auto"]
RECESSIONS = [
{start: new Date("2020-02-01"), end: new Date("2020-04-30"), label: "COVID-19"}
]
fmtDate = d => d.toLocaleString("en-US", {month: "short", year: "numeric"})
fmtPct = d => `${d > 0 ? "+" : ""}${d}%`Latest Survey
Code
{
if (!data_ok) return html`<div class="chart-error">⚠ Run <code>python analysis/aggregate_sloos.py</code> to generate dashboard data.</div>`
const latest = data.reduce((max, d) => d.survey_date > max ? d.survey_date : max, new Date(0))
const latestRows = data.filter(d => d.survey_date.getTime() === latest.getTime())
const strip = html`<div class="kpi-strip"></div>`
for (const type of LOAN_TYPES) {
const row = latestRows.find(d => d.loan_type === type)
const val = row ? row.net_pct_tightening : null
const color = val > 20 ? "#b45309" : val < 0 ? "#16a34a" : "#1C1C1E"
const card = html`<div class="kpi-card">
<div class="kpi-value" style="color:${color}">${val !== null ? fmtPct(val) : "—"}</div>
<div class="kpi-label">${type}</div>
</div>`
strip.appendChild(card)
}
const surveyed = html`<div style="font-family:'DM Mono',monospace;font-size:0.7rem;color:#9A9A9A;margin-bottom:1.5rem;">
Survey period: ${fmtDate(latest)}
</div>`
return html`<div>${surveyed}${strip}</div>`
}Standards Over Time
Code
{
if (!data_ok || data.length === 0) return html`<div class="chart-loading">No data</div>`
const now = new Date()
const cutoff = timeWindow === "2Y" ? new Date(now.getFullYear() - 2, now.getMonth())
: timeWindow === "5Y" ? new Date(now.getFullYear() - 5, now.getMonth())
: new Date(0)
const filtered = data.filter(d =>
selectedTypes.includes(d.loan_type) && d.survey_date >= cutoff
)
const w = Math.min(800, window.innerWidth - 48)
return Plot.plot({
marks: [
// Recession bands
...RECESSIONS.map(r => Plot.rect([r], {
x1: "start", x2: "end",
y1: -100, y2: 100,
fill: "#888", fillOpacity: 0.07
})),
// Zero baseline
Plot.ruleY([0], {stroke: "#9CA3AF", strokeWidth: 1, strokeDasharray: "4,3"}),
// +20% watch level
Plot.ruleY([20], {stroke: "#b45309", strokeWidth: 1, strokeDasharray: "2,4", strokeOpacity: 0.5}),
Plot.text([{y: 20, label: "+20% watch"}], {
x: cutoff > new Date(0) ? cutoff : new Date("2018-07-01"),
y: "y",
text: "label",
dy: -6, dx: 4,
fill: "#b45309", fillOpacity: 0.6,
fontSize: 10,
fontFamily: "DM Mono, monospace"
}),
// Lines
Plot.line(filtered, {
x: "survey_date",
y: "net_pct_tightening",
stroke: d => COLORS[d.loan_type],
strokeWidth: 2.5,
curve: "monotone-x"
}),
// Survey dots
Plot.dot(filtered, {
x: "survey_date",
y: "net_pct_tightening",
fill: d => COLORS[d.loan_type],
r: 3.5,
strokeWidth: 1,
stroke: "white"
}),
// Hover crosshair + tip
Plot.crosshair(filtered, {
x: "survey_date",
y: "net_pct_tightening",
color: d => COLORS[d.loan_type]
}),
Plot.tip(filtered, Plot.pointer({
x: "survey_date",
y: "net_pct_tightening",
title: d => `${d.loan_type}\n${fmtDate(d.survey_date)}: ${fmtPct(d.net_pct_tightening)}`
}))
],
x: {
label: null,
type: "time",
tickFormat: "%b '%y"
},
y: {
label: "Net % tightening (%)",
tickFormat: fmtPct,
grid: true,
zero: true
},
color: {
domain: LOAN_TYPES,
range: Object.values(COLORS),
legend: true
},
width: w,
height: 340,
marginLeft: 65,
marginRight: 20,
style: {background: "transparent", fontSize: "12px", fontFamily: "DM Mono, monospace"}
})
}Last Three Surveys
Code
{
if (!data_ok || data.length === 0) return null
const surveys = [...new Set(data.map(d => d.survey_date.getTime()))]
.sort((a, b) => b - a)
.slice(0, 3)
.map(t => new Date(t))
// cell color: red = tightening, green = easing, grey = neutral
function cellColor(v) {
if (v > 20) return {bg: "#FEE2E2", fg: "#991B1B"}
if (v > 0) return {bg: "#FEF3C7", fg: "#92400E"}
if (v < 0) return {bg: "#DCFCE7", fg: "#166534"}
return {bg: "transparent", fg: "#6B7280"}
}
const rows = LOAN_TYPES.map(lt => {
const cells = surveys.map(s => {
const row = data.find(d => d.loan_type === lt && d.survey_date.getTime() === s.getTime())
return row ? row.net_pct_tightening : null
})
return {lt, cells}
})
const mono = "font-family:'DM Mono',monospace"
const thead = html`<thead><tr>
<th style="${mono};font-size:0.7rem;text-align:left;padding:0.6rem 1rem;font-weight:500;color:#9A9A9A;border-bottom:1px solid #D8D3C8"></th>
${surveys.map((s, i) => html`<th style="${mono};font-size:0.7rem;text-align:right;padding:0.6rem 1rem;font-weight:${i===0?700:400};color:${i===0?"#1C1C1E":"#9A9A9A"};border-bottom:1px solid #D8D3C8">
${fmtDate(s)}${i===0?" ★":""}
</th>`)}
</tr></thead>`
const tbody = html`<tbody>${rows.map(({lt, cells}) => {
const color = COLORS[lt]
return html`<tr>
<td style="${mono};font-size:0.75rem;padding:0.55rem 1rem;color:${color};font-weight:500;border-bottom:1px solid #EDEAE2">${lt}</td>
${cells.map((v, i) => {
const {bg, fg} = v !== null ? cellColor(v) : {bg:"transparent", fg:"#ccc"}
return html`<td style="${mono};font-size:0.8rem;text-align:right;padding:0.55rem 1rem;
background:${i===0?bg:"transparent"};color:${i===0?fg:"#6B7280"};
font-weight:${i===0?600:400};border-bottom:1px solid #EDEAE2">
${v !== null ? fmtPct(v) : "—"}
</td>`
})}
</tr>`
})}</tbody>`
return html`<table style="width:100%;border-collapse:collapse;border:1px solid #D8D3C8;border-radius:4px;overflow:hidden">
${thead}${tbody}
</table>`
}