[object Object]

← back to Rentv

Live Rates & Markets page (/markets.html): Treasury curve + REIT benchmarks + spreads via Yahoo; /api/markets

e8f63f034322109a281aae91e5181ab7a050c5db · 2026-07-20 14:29:24 -0700 · steve

Files touched

Diff

commit e8f63f034322109a281aae91e5181ab7a050c5db
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Jul 20 14:29:24 2026 -0700

    Live Rates & Markets page (/markets.html): Treasury curve + REIT benchmarks + spreads via Yahoo; /api/markets
---
 data/markets.json       |  76 ++++++++++++++++++++++++++++++++++
 public/markets.html     | 106 ++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/pull-ticker.mjs |  71 +++++++++++++++++---------------
 server.js               |   6 +++
 4 files changed, 227 insertions(+), 32 deletions(-)

diff --git a/data/markets.json b/data/markets.json
new file mode 100644
index 00000000..58fc6db9
--- /dev/null
+++ b/data/markets.json
@@ -0,0 +1,76 @@
+{
+  "fetched_at": "2026-07-20T21:27:36.267Z",
+  "source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
+  "curve": [
+    {
+      "label": "3-Month",
+      "value": "3.71%",
+      "change_bps": 0,
+      "dir": "flat"
+    },
+    {
+      "label": "5-Year",
+      "value": "4.33%",
+      "change_bps": 6,
+      "dir": "up"
+    },
+    {
+      "label": "10-Year",
+      "value": "4.60%",
+      "change_bps": 6,
+      "dir": "up"
+    },
+    {
+      "label": "30-Year",
+      "value": "5.12%",
+      "change_bps": 5,
+      "dir": "up"
+    }
+  ],
+  "spreads": [
+    {
+      "label": "30Y – 10Y (curve)",
+      "value": "+52 bps",
+      "dir": "up"
+    },
+    {
+      "label": "10Y – 3M (curve)",
+      "value": "+89 bps",
+      "dir": "up"
+    }
+  ],
+  "reits": [
+    {
+      "label": "DJ U.S. Real Estate",
+      "value": "397.62",
+      "change_pct": -0.58,
+      "dir": "down"
+    },
+    {
+      "label": "MSCI U.S. REIT",
+      "value": "1,289.55",
+      "change_pct": 0,
+      "dir": "flat"
+    },
+    {
+      "label": "Vanguard REIT ETF (VNQ)",
+      "value": "$99.48",
+      "change_pct": -0.59,
+      "dir": "down"
+    }
+  ],
+  "fundamentals": [
+    {
+      "label": "LA OFFICE VAC",
+      "value": "23.1%",
+      "as_of": "Q2 2026",
+      "source": "CBRE/JLL market reports"
+    },
+    {
+      "label": "IE INDUSTRIAL CAP",
+      "value": "5.2%",
+      "as_of": "Q2 2026",
+      "source": "Colliers IE industrial"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/public/markets.html b/public/markets.html
new file mode 100644
index 00000000..86e47860
--- /dev/null
+++ b/public/markets.html
@@ -0,0 +1,106 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Rates &amp; Markets — RENTV.com</title>
+<meta name="description" content="Live Treasury yield curve, REIT indices and Western-U.S. CRE fundamentals — RENTV.com Rates &amp; Markets.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+  :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--up:#0f8a4d;--dn:#c8102e;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{font-family:var(--sans);color:var(--ink);background:var(--wash);line-height:1.45}
+  a{color:inherit;text-decoration:none}
+  .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+  .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+  .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+  .util .live{display:inline-flex;align-items:center;gap:7px;letter-spacing:.05em;text-transform:uppercase;font-size:11px}
+  .util .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
+  @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
+  .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+  .mast{background:#fff;border-bottom:2px solid var(--ink);padding:16px 0 12px}
+  .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+  .logo{font-family:var(--serif);font-weight:700;font-size:32px;letter-spacing:-1px}.logo span{color:var(--red)}
+  .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+  nav{background:#fff;border-bottom:1px solid var(--line)}
+  nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+  nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap}
+  nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+  .head{padding:30px 0 6px}
+  .head h1{font-family:var(--serif);font-size:34px;letter-spacing:-.5px}
+  .head p{color:var(--sub);font-size:14px;margin-top:4px}
+  .head .fresh{font-size:12px;color:var(--sub);margin-top:8px}
+  section{margin:26px 0}
+  h2.sec{font-size:14px;text-transform:uppercase;letter-spacing:.08em;border-top:3px solid var(--ink);padding-top:10px;margin-bottom:14px}
+  .cards{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
+  .cards.sp{grid-template-columns:repeat(2,1fr)}
+  .cards.re{grid-template-columns:repeat(3,1fr)}
+  .card{background:#fff;border:1px solid var(--line);border-radius:8px;padding:16px 18px}
+  .card .l{font-size:12px;color:var(--sub);text-transform:uppercase;letter-spacing:.05em}
+  .card .v{font-family:var(--serif);font-size:30px;font-weight:700;margin-top:4px;line-height:1}
+  .card .c{font-size:13px;font-weight:700;margin-top:6px}
+  .up{color:var(--up)}.dn{color:var(--dn)}.flat{color:var(--sub)}
+  .note{font-size:12px;color:var(--sub);margin-top:8px}
+  footer{background:var(--ink);color:#8b939b;margin-top:40px;padding:26px 0;font-size:12px}
+  footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
+  @media(max-width:860px){.cards{grid-template-columns:1fr 1fr}.cards.re{grid-template-columns:1fr}}
+</style>
+</head>
+<body>
+  <div class="util"><div class="wrap">
+    <span class="live"><span class="dot"></span> Live market data</span>
+    <div class="acts"><a href="/">Home</a><a href="/review">The REview</a><a class="sub" href="/#news">Subscribe</a></div>
+  </div></div>
+  <header class="mast"><div class="wrap">
+    <a class="logo" href="/">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+    <div style="font-size:12px;color:var(--sub);text-align:right">Western U.S. Edition<br><span id="today"></span></div>
+  </div></header>
+  <nav><div class="wrap">
+    <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a>
+    <a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a>
+    <a href="/retail">Retail</a><a class="on" href="/markets.html">Rates &amp; Markets</a>
+  </div></nav>
+
+  <main class="wrap">
+    <div class="head">
+      <h1>Rates &amp; Markets</h1>
+      <p>The rate backdrop that moves commercial real estate — Treasury curve, REIT benchmarks and Western-U.S. fundamentals.</p>
+      <div class="fresh" id="fresh">Loading live market data…</div>
+    </div>
+
+    <section><h2 class="sec">U.S. Treasury Yield Curve</h2><div class="cards" id="curve"></div>
+      <div class="note">Higher long rates raise borrowing costs and pressure cap rates. Daily change shown in basis points.</div></section>
+    <section><h2 class="sec">Curve Spreads</h2><div class="cards sp" id="spreads"></div>
+      <div class="note">A steeper curve (positive spread) generally signals easier financing ahead; inversion has historically preceded slowdowns.</div></section>
+    <section><h2 class="sec">REIT Benchmarks</h2><div class="cards re" id="reits"></div>
+      <div class="note">Public real-estate equities lead private CRE pricing. Daily change shown as a percentage.</div></section>
+    <section><h2 class="sec">Western-U.S. Fundamentals</h2><div class="cards sp" id="fund"></div>
+      <div class="note">Market-report figures, updated quarterly (CBRE / JLL / Colliers).</div></section>
+  </main>
+
+  <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News, Western U.S.</span><span>Market data via Yahoo Finance · delayed · informational only</span></div></footer>
+
+<script>
+document.getElementById('today').textContent=new Date().toLocaleDateString(undefined,{weekday:'long',year:'numeric',month:'long',day:'numeric'});
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+function chg(m){
+  if(m.change_bps!=null){const a=Math.abs(m.change_bps);return m.dir==='up'?'<span class="up">▲ '+a+' bps</span>':m.dir==='down'?'<span class="dn">▼ '+a+' bps</span>':'<span class="flat">— unch</span>';}
+  if(m.change_pct!=null){const a=Math.abs(m.change_pct);return m.dir==='up'?'<span class="up">▲ '+a+'%</span>':m.dir==='down'?'<span class="dn">▼ '+a+'%</span>':'<span class="flat">— unch</span>';}
+  return '';
+}
+const card=m=>'<div class="card"><div class="l">'+esc(m.label)+'</div><div class="v">'+esc(m.value)+'</div><div class="c">'+chg(m)+(m.as_of?'<span class="flat" style="font-weight:400"> · '+esc(m.as_of)+'</span>':'')+'</div></div>';
+async function load(){
+  try{
+    const d=await (await fetch('/api/markets')).json();
+    document.getElementById('curve').innerHTML=(d.curve||[]).map(card).join('');
+    document.getElementById('spreads').innerHTML=(d.spreads||[]).map(card).join('');
+    document.getElementById('reits').innerHTML=(d.reits||[]).map(card).join('');
+    document.getElementById('fund').innerHTML=(d.fundamentals||[]).map(card).join('');
+    const mins=Math.max(0,Math.round((Date.now()-new Date(d.fetched_at))/60000));
+    document.getElementById('fresh').textContent=(d.source||'Live market data')+' · '+(mins<2?'updated just now':'updated '+mins+' min ago');
+  }catch(e){document.getElementById('fresh').textContent='Market data temporarily unavailable — retrying…';}
+}
+load(); setInterval(load,120000);
+</script>
+</body>
+</html>
diff --git a/scripts/pull-ticker.mjs b/scripts/pull-ticker.mjs
index e2dfd3ca..4925b92c 100644
--- a/scripts/pull-ticker.mjs
+++ b/scripts/pull-ticker.mjs
@@ -1,55 +1,62 @@
 #!/usr/bin/env node
-// Live ticker engine for rentv-v1 — writes data/ticker.json the header serves.
-// LIVE (free, no key): 10-YR Treasury yield (^TNX) + CRE index (Dow Jones U.S. Real
-// Estate ^DJUSRE) via Yahoo Finance. Market fundamentals (LA office vac, IE industrial
-// cap) are quarterly figures kept as maintainable config here (not real-time-fetchable free).
+// Live market engine for rentv-v1 — writes data/ticker.json (header strip) and
+// data/markets.json (the /markets Rates & Markets dashboard). LIVE via Yahoo Finance
+// (free, no key): Treasury curve (3mo/5yr/10yr/30yr) + REIT indices (DJUSRE/RMZ/VNQ).
+// LA office vacancy + IE industrial cap are quarterly figures kept as config below.
 import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
 import { dirname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 const HERE = dirname(fileURLToPath(import.meta.url));
 const OUT = join(HERE, '..', 'data'); mkdirSync(OUT, { recursive: true });
-const FILE = join(OUT, 'ticker.json');
 const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36 RENTV-v1';
 
-// Quarterly CRE fundamentals — update from CBRE/JLL/Colliers market reports.
 const FUNDAMENTALS = {
   la_office_vac:    { label: 'LA OFFICE VAC',    value: '23.1%', as_of: 'Q2 2026', source: 'CBRE/JLL market reports' },
   ie_industrial_cap:{ label: 'IE INDUSTRIAL CAP',value: '5.2%',  as_of: 'Q2 2026', source: 'Colliers IE industrial' },
 };
-// Rebase factor so the Dow Jones U.S. Real Estate index reads as a ~4,000s "RENTV CRE Index"
-// (matches the site's index scale; % change is identical to the underlying real index).
-const CRE_SCALE = 10.51;
+const CRE_SCALE = 10.51; // rebase DJ U.S. Real Estate to the site's ~4,000s index scale
 
 async function yahoo(sym) {
   const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=2d`;
   const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15000) });
-  const j = await r.json();
-  const m = j?.chart?.result?.[0]?.meta;
-  if (!m || m.regularMarketPrice == null) throw new Error('no price for ' + sym);
+  const m = (await r.json())?.chart?.result?.[0]?.meta;
+  if (!m || m.regularMarketPrice == null) throw new Error('no price ' + sym);
   return { price: m.regularMarketPrice, prev: m.chartPreviousClose ?? m.previousClose ?? m.regularMarketPrice };
 }
+const dir = d => d > 0.001 ? 'up' : d < -0.001 ? 'down' : 'flat';
+const pct = (p, pv) => pv ? +(((p - pv) / pv) * 100).toFixed(2) : 0;
+const bps = (p, pv) => Math.round((p - pv) * 100);
+const q = async (sym) => { try { return await yahoo(sym); } catch { return null; } };
 
-const prior = (() => { try { return JSON.parse(readFileSync(FILE, 'utf8')); } catch { return {}; } })();
-const pct = (p, prev) => prev ? +(((p - prev) / prev) * 100).toFixed(2) : 0;
-const dir = (d) => d > 0.001 ? 'up' : d < -0.001 ? 'down' : 'flat';
+// Fetch everything once
+const [irx, fvx, tnx, tyx, djusre, rmz, vnq] = await Promise.all(
+  ['^IRX', '^FVX', '^TNX', '^TYX', '^DJUSRE', '^RMZ', 'VNQ'].map(q));
 
-const out = { fetched_at: new Date().toISOString(), ...FUNDAMENTALS };
+// ── ticker.json (header strip) ──
+const prior = (() => { try { return JSON.parse(readFileSync(join(OUT, 'ticker.json'), 'utf8')); } catch { return {}; } })();
+const ticker = { fetched_at: new Date().toISOString(), ...FUNDAMENTALS };
+ticker.cre_index = djusre
+  ? { label: 'CRE INDEX', value: Math.round(djusre.price * CRE_SCALE).toLocaleString('en-US'), change_pct: pct(djusre.price, djusre.prev), dir: dir(pct(djusre.price, djusre.prev)), source: 'Dow Jones U.S. Real Estate' }
+  : { ...(prior.cre_index || { label: 'CRE INDEX', value: '—', dir: 'flat' }), stale: true };
+ticker.ten_yr = tnx
+  ? { label: '10-YR', value: tnx.price.toFixed(2) + '%', change_bps: bps(tnx.price, tnx.prev), dir: dir(bps(tnx.price, tnx.prev)), source: 'CBOE 10-Yr (^TNX)' }
+  : { ...(prior.ten_yr || { label: '10-YR', value: '—', dir: 'flat' }), stale: true };
+writeFileSync(join(OUT, 'ticker.json'), JSON.stringify(ticker, null, 2));
 
-// CRE INDEX (Dow Jones U.S. Real Estate, rebased)
-try {
-  const d = await yahoo('^DJUSRE');
-  const cp = pct(d.price, d.prev);
-  out.cre_index = { label: 'CRE INDEX', value: Math.round(d.price * CRE_SCALE).toLocaleString('en-US'),
-    change_pct: cp, dir: dir(cp), source: 'Dow Jones U.S. Real Estate' };
-} catch (e) { out.cre_index = prior.cre_index || { label: 'CRE INDEX', value: '—', change_pct: 0, dir: 'flat' }; out.cre_index.stale = true; }
+// ── markets.json (Rates & Markets dashboard) ──
+const rate = (label, d) => d ? { label, value: d.price.toFixed(2) + '%', change_bps: bps(d.price, d.prev), dir: dir(bps(d.price, d.prev)) } : { label, value: '—', dir: 'flat', stale: true };
+const idx = (label, d, prefix = '') => d ? { label, value: prefix + d.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), change_pct: pct(d.price, d.prev), dir: dir(pct(d.price, d.prev)) } : { label, value: '—', dir: 'flat', stale: true };
+const spread = (label, a, b) => (a && b) ? (() => { const s = Math.round((a.price - b.price) * 100); return { label, value: (s >= 0 ? '+' : '') + s + ' bps', dir: dir(s) }; })() : { label, value: '—', dir: 'flat' };
 
-// 10-YR Treasury yield (^TNX quotes the yield directly)
-try {
-  const d = await yahoo('^TNX');
-  const bps = Math.round((d.price - d.prev) * 100);
-  out.ten_yr = { label: '10-YR', value: d.price.toFixed(2) + '%', change_bps: bps, dir: dir(bps), source: 'CBOE 10-Yr (^TNX)' };
-} catch (e) { out.ten_yr = prior.ten_yr || { label: '10-YR', value: '—', change_bps: 0, dir: 'flat' }; out.ten_yr.stale = true; }
+const markets = {
+  fetched_at: new Date().toISOString(),
+  source: 'Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)',
+  curve: [ rate('3-Month', irx), rate('5-Year', fvx), rate('10-Year', tnx), rate('30-Year', tyx) ],
+  spreads: [ spread('30Y – 10Y (curve)', tyx, tnx), spread('10Y – 3M (curve)', tnx, irx) ],
+  reits: [ idx('DJ U.S. Real Estate', djusre), idx('MSCI U.S. REIT', rmz), idx('Vanguard REIT ETF (VNQ)', vnq, '$') ],
+  fundamentals: [ FUNDAMENTALS.la_office_vac, FUNDAMENTALS.ie_industrial_cap ],
+};
+writeFileSync(join(OUT, 'markets.json'), JSON.stringify(markets, null, 2));
 
-writeFileSync(FILE, JSON.stringify(out, null, 2));
-console.log('ticker.json:', out.cre_index.value, '| 10-YR', out.ten_yr.value,
-  '| CRE', out.cre_index.change_pct + '%', out.cre_index.stale ? '(stale)' : '');
+const live = [irx, fvx, tnx, tyx, djusre, rmz, vnq].filter(Boolean).length;
+console.log(`ticker+markets written | CRE ${ticker.cre_index.value} | 10-YR ${ticker.ten_yr.value} | ${live}/7 live symbols`);
diff --git a/server.js b/server.js
index 4b2d8515..897d92d7 100644
--- a/server.js
+++ b/server.js
@@ -34,6 +34,12 @@ app.get('/api/ticker', (_q, r) => {
   r.json(readJSON('ticker.json', {}));
 });
 
+// ── Rates & Markets dashboard data (Treasury curve + REIT indices, live via Yahoo) ──
+app.get('/api/markets', (_q, r) => {
+  r.set('Cache-Control', 'public, max-age=120');
+  r.json(readJSON('markets.json', {}));
+});
+
 // ── The REview: live CRE video catalog (synced from rentvreview.com by cron) ──
 app.get('/api/videos', (_q, r) => {
   r.set('Cache-Control', 'public, max-age=300');

← d9ec1da5 add in-shell article.html + section.html reader templates; w  ·  back to Rentv  ·  auto-save: 2026-07-20T14:32:39 (2 files) — data/news.json da 71395b46 →