diff --git a/mcp-server/entity.ts b/mcp-server/entity.ts index 26a6dd2..627dad0 100644 --- a/mcp-server/entity.ts +++ b/mcp-server/entity.ts @@ -1106,7 +1106,7 @@ async function fetchSecProfile(cik: number | string): Promise<{ // Stooq — free daily quote CSV. Format (column order from the response): // Symbol,Date,Time,Open,High,Low,Close,Volume -async function fetchStooqQuote(ticker: string): Promise<{ +export async function fetchStooqQuote(ticker: string): Promise<{ price?: number; price_date?: string; open?: number; diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 6772b07..a18bc50 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -876,6 +876,45 @@ async function main() { } } + // Batch ticker quotes — used by the profiler page's scrolling + // ticker basket. Stooq's HTTP CSV API is single-symbol per call, + // so this fans out N tickers in parallel and returns a flat + // map. Non-US tickers (HOC.DE, SKA-B.ST, LLC.AX) won't have a + // Stooq.us entry; we surface that as null so the UI can render + // them with a "—" placeholder. + if (url.pathname === "/intelligence/ticker_quotes" && req.method === "POST") { + const start = Date.now(); + const b = await json(); + const tickers: string[] = Array.isArray(b.tickers) ? b.tickers.map((t: any) => String(t).toUpperCase()).filter(Boolean) : []; + if (!tickers.length) return ok({ quotes: {}, duration_ms: 0 }); + const { fetchStooqQuote } = await import("./entity.js"); + const dedup = Array.from(new Set(tickers)).slice(0, 50); + const results = await Promise.all(dedup.map(async (t) => { + // Skip non-US suffixes — Stooq.us won't have them + if (t.includes(".")) return [t, null] as const; + try { + const q = await fetchStooqQuote(t); + if (!q || !q.price) return [t, null] as const; + const change_pct = q.open && q.open > 0 ? ((q.price - q.open) / q.open) * 100 : null; + return [t, { + ticker: t, + price: q.price, + price_date: q.price_date, + open: q.open, + high: q.high, + low: q.low, + day_change_pct: change_pct, + stooq_url: `https://stooq.com/q/?s=${t.toLowerCase()}.us`, + }] as const; + } catch { + return [t, null] as const; + } + })); + const quotes: Record = {}; + for (const [t, q] of results) quotes[t] = q; + return ok({ quotes, count: dedup.length, duration_ms: Date.now() - start }); + } + // Profiler index — directory of every contractor that has filed // a Chicago permit recently, ranked by permit count + total // cost. Each name in the response links to the full /contractor diff --git a/mcp-server/profiler.html b/mcp-server/profiler.html index c89e124..039f718 100644 --- a/mcp-server/profiler.html +++ b/mcp-server/profiler.html @@ -36,6 +36,34 @@ td.role .pill{display:inline-block;padding:2px 7px;border-radius:9px;font-size:9 .ticker-pill.parent{background:#1a1410;border:1px solid #d2992288;color:#d29922} .ticker-pill.associated{background:#0d1830;border:1px solid #58a6ff66;color:#58a6ff} .ticker-pill.exact{background:#0d2818;border:1px solid #2ea043;color:#3fb950} + +/* Scrolling ticker basket — top strip showing every public issuer + the profiler index has surfaced, with live price + day-change. */ +.basket-wrap{background:#0a0d12;border:1px solid #171d27;border-radius:10px;margin-bottom:14px;overflow:hidden;position:relative} +.basket-label{padding:10px 16px 4px;font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1.3px;font-weight:600;display:flex;justify-content:space-between;align-items:baseline} +.basket-label .meta{font-weight:400;color:#3d444d;font-size:10px;text-transform:none;letter-spacing:0} +.basket-track{display:flex;gap:0;overflow-x:auto;scroll-behavior:smooth;padding:6px 8px 12px;scrollbar-width:thin;scrollbar-color:#21262d transparent} +.basket-track::-webkit-scrollbar{height:6px} +.basket-track::-webkit-scrollbar-thumb{background:#21262d;border-radius:3px} +.basket-track::-webkit-scrollbar-thumb:hover{background:#388bfd} +.bk-card{flex:0 0 auto;min-width:140px;background:#0d1117;border:1px solid #21262d;border-radius:8px;padding:10px 12px;margin:0 4px;cursor:pointer;transition:all 0.12s;position:relative} +.bk-card:hover{border-color:#58a6ff;background:#0d1a30;transform:translateY(-1px)} +.bk-card.selected{border-color:#58a6ff;background:#0d1a30;box-shadow:0 0 0 1px #58a6ff;} +.bk-card .tk{font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;font-weight:700;color:#e6edf3;letter-spacing:0.3px} +.bk-card .px{font-family:ui-monospace,SFMono-Regular,monospace;font-size:14px;font-weight:600;color:#e6edf3;margin-top:3px;font-variant-numeric:tabular-nums} +.bk-card .ch{font-family:ui-monospace,monospace;font-size:11px;margin-top:1px;font-variant-numeric:tabular-nums} +.bk-card .ch.up{color:#3fb950} +.bk-card .ch.down{color:#f85149} +.bk-card .ch.flat{color:#545d68} +.bk-card .meta{font-size:9px;color:#545d68;margin-top:5px;text-transform:uppercase;letter-spacing:0.6px} +.bk-card .kind-bar{position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:8px 0 0 8px} +.bk-card .kind-bar.exact,.bk-card .kind-bar.direct{background:#3fb950} +.bk-card .kind-bar.parent{background:#d29922} +.bk-card .kind-bar.associated{background:#58a6ff} +.bk-card .kind-bar.mixed{background:linear-gradient(180deg,#3fb950 0%,#58a6ff 100%)} +.bk-card.no-quote .px{color:#545d68} +.basket-empty{padding:18px;font-size:11px;color:#545d68;font-style:italic;text-align:center} +.basket-clear{margin-left:8px;font-size:10px;color:#58a6ff;cursor:pointer;border:none;background:none;text-decoration:underline} .cost-band-1{color:#3fb950} .cost-band-2{color:#d29922} .cost-band-3{color:#f85149} @@ -53,6 +81,13 @@ td.role .pill{display:inline-block;padding:2px 7px;border-radius:9px;font-size:9
+