demo: profiler — scrolling ticker basket with live prices + click-to-filter
J asked: "kind of like a scrolling ticker that has all of the companies
and their stock prices and where they fit in the map." Implemented as
a horizontal-scroll strip at the top of /profiler:
9 public issuers in this view · quotes via Stooq · 669ms
┌────┬────┬────┬────┬────┬────┬────┐
│TGT │JPM │BALY│ACRE│FCBC│NREF│LSBK│ ← live price + day-change per
│129 │311 │... │... │... │... │... │ ticker, color-banded by
│+.17│+1.5│... │... │... │... │... │ attribution kind
└────┴────┴────┴────┴────┴────┴────┘
Each card carries:
- ticker + live price + day-change % (red/green)
- attribution count + kind (exact / direct / parent / associated)
- left bar color = strongest attribution kind (green for direct
issuer, amber for parent, blue for co-permit associated, gradient
when both direct and associated apply)
- tooltip on hover lists the contractors attributed to this ticker
- click toggles a filter on the table below — clicking TGT cuts the
200-row list down to just TARGET CORPORATION + TORNOW, KYLE F
(Target's primary co-permit contractor)
Server-side:
- entity.ts exports fetchStooqQuote (was internal)
- new POST /intelligence/ticker_quotes — accepts {tickers: [...]},
fans out to Stooq.us in parallel, returns
{ticker, price, price_date, open, high, low, day_change_pct,
stooq_url} per symbol or null for non-US listings (HOC.DE, SKA-B.ST,
LLC.AX). Capped at 50 symbols per call.
Front-end:
- mcp-server/profiler.html — new .basket-wrap section above the
controls. buildBasket() runs after profiler_index loads:
1. Aggregates unique tickers from .tickers.direct + .associated
across all surfaced contractors
2. Renders shells immediately (ticker symbol + "—" placeholder)
3. Batch-fetches quotes via /intelligence/ticker_quotes
4. Updates each card with price + day-change in place
Click on a card sets a tickerFilter; render() skips rows whose
attributions don't include that ticker. "clear filter" button on
the basket strip resets it.
Verified end-to-end on devop.live/lakehouse/profiler:
Default load → 9 issuers, live prices populated in 669ms
TGT click → table filters to TARGET CORPORATION + TORNOW, KYLE F
(the contractor who runs 3 of Target's recent permits
gets the TGT correlation indicator)
JPM card → $311.63, +1.55% — JPMorgan-adjacent contractors
Tooltip → list of contractors attributed to the ticker
This commit is contained in:
parent
ba41ad2846
commit
aa56fbce61
@ -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;
|
||||
|
||||
@ -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<string, any> = {};
|
||||
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
|
||||
|
||||
@ -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
|
||||
</nav>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="basket-wrap" id="basket-wrap" style="display:none">
|
||||
<div class="basket-label">
|
||||
<span><span id="bk-count">0</span> public issuers in this view <span class="meta" id="bk-meta"></span></span>
|
||||
<button class="basket-clear" id="bk-clear" style="display:none" type="button">clear filter</button>
|
||||
</div>
|
||||
<div class="basket-track" id="basket"></div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input class="s" id="q" type="text" placeholder="Filter by contractor name (e.g., Target, Turner)" autocomplete="off">
|
||||
<select id="since">
|
||||
@ -79,6 +114,7 @@ document.getElementById('back-console').href = P+'/console';
|
||||
|
||||
var sortKey='total_cost', sortDir='desc';
|
||||
var lastRows=[];
|
||||
var tickerFilter=null; // selected ticker for filtering the table
|
||||
|
||||
function clearChildren(el){ while(el.firstChild) el.removeChild(el.firstChild); }
|
||||
function fmt$(n){
|
||||
@ -110,6 +146,8 @@ function load(){
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
lastRows = d.contractors||[];
|
||||
document.getElementById('meta').textContent=lastRows.length+' contractors · '+(d.duration_ms||0)+'ms';
|
||||
// Build the ticker basket from the surfaced rows
|
||||
buildBasket();
|
||||
var totalCost = lastRows.reduce(function(s,r){return s+(r.total_cost||0)},0);
|
||||
var totalPermits = lastRows.reduce(function(s,r){return s+(r.permits||0)},0);
|
||||
var sumDiv=document.getElementById('summary');
|
||||
@ -134,16 +172,104 @@ function load(){
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregate every public ticker the profiler index surfaced, with a
|
||||
// kind hierarchy (exact > direct > parent > associated) and the count
|
||||
// of contractors each ticker is attributed to. Then fetch live quotes
|
||||
// in one batch and render the scrolling basket.
|
||||
function buildBasket(){
|
||||
var byTicker = {};
|
||||
lastRows.forEach(function(r){
|
||||
var ts = (r.tickers && r.tickers.direct ? r.tickers.direct : []).concat(r.tickers && r.tickers.associated ? r.tickers.associated : []);
|
||||
ts.forEach(function(t){
|
||||
if(!t || !t.ticker) return;
|
||||
if(!byTicker[t.ticker]) byTicker[t.ticker] = {ticker:t.ticker, kinds:new Set(), count:0, contractors:[], matched_name:t.matched_name||t.partner_name||null};
|
||||
byTicker[t.ticker].kinds.add(t.via);
|
||||
byTicker[t.ticker].count++;
|
||||
if(byTicker[t.ticker].contractors.length < 5) byTicker[t.ticker].contractors.push(r.name);
|
||||
});
|
||||
});
|
||||
var basketRows = Object.values(byTicker)
|
||||
.map(function(b){
|
||||
// Pick a single 'kind' for the bar color: direct/exact wins, then parent, then associated.
|
||||
var k = b.kinds.has('exact')?'exact':b.kinds.has('direct')?'direct':b.kinds.has('parent')?'parent':b.kinds.has('associated')?'associated':'mixed';
|
||||
if(b.kinds.size>1 && (b.kinds.has('exact')||b.kinds.has('direct')) && b.kinds.has('associated')) k='mixed';
|
||||
return Object.assign({}, b, {kinds:Array.from(b.kinds), kind:k});
|
||||
})
|
||||
.sort(function(a,b){return b.count - a.count});
|
||||
var wrap = document.getElementById('basket-wrap');
|
||||
var track = document.getElementById('basket');
|
||||
clearChildren(track);
|
||||
if(!basketRows.length){
|
||||
wrap.style.display='block';
|
||||
var emp=document.createElement('div'); emp.className='basket-empty';
|
||||
emp.textContent='No public issuers in this view. Try a wider filter or "since 2020" history.';
|
||||
track.appendChild(emp);
|
||||
document.getElementById('bk-count').textContent='0';
|
||||
document.getElementById('bk-meta').textContent='';
|
||||
return;
|
||||
}
|
||||
wrap.style.display='block';
|
||||
document.getElementById('bk-count').textContent=basketRows.length;
|
||||
document.getElementById('bk-meta').textContent='loading prices…';
|
||||
// Render shells immediately, then fill in prices when the batch returns
|
||||
basketRows.forEach(function(b){
|
||||
var card=document.createElement('div'); card.className='bk-card no-quote';
|
||||
card.dataset.ticker=b.ticker;
|
||||
var bar=document.createElement('div'); bar.className='kind-bar '+b.kind; card.appendChild(bar);
|
||||
var tk=document.createElement('div'); tk.className='tk'; tk.textContent=b.ticker; card.appendChild(tk);
|
||||
var px=document.createElement('div'); px.className='px'; px.textContent='—'; card.appendChild(px);
|
||||
var ch=document.createElement('div'); ch.className='ch flat'; ch.textContent=' '; card.appendChild(ch);
|
||||
var meta=document.createElement('div'); meta.className='meta';
|
||||
meta.textContent=b.count+' attribution'+(b.count===1?'':'s')+' · '+b.kinds.join('+');
|
||||
card.appendChild(meta);
|
||||
card.title=(b.matched_name||b.ticker)+'\n'+b.contractors.slice(0,5).join('\n')+(b.count>5?'\n…':'');
|
||||
card.onclick=function(){
|
||||
tickerFilter = (tickerFilter===b.ticker) ? null : b.ticker;
|
||||
Array.prototype.forEach.call(track.children, function(c){
|
||||
c.classList.toggle('selected', c.dataset && c.dataset.ticker===tickerFilter);
|
||||
});
|
||||
document.getElementById('bk-clear').style.display = tickerFilter ? 'inline' : 'none';
|
||||
render();
|
||||
};
|
||||
track.appendChild(card);
|
||||
});
|
||||
// Batch-fetch quotes and update each card
|
||||
fetch(P+'/intelligence/ticker_quotes',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({tickers:basketRows.map(function(b){return b.ticker})})
|
||||
}).then(function(r){return r.json()}).then(function(qd){
|
||||
var quotes=qd.quotes||{};
|
||||
document.getElementById('bk-meta').textContent='quotes via Stooq · '+(qd.duration_ms||0)+'ms';
|
||||
Array.prototype.forEach.call(track.children, function(card){
|
||||
var t=card.dataset.ticker; var q=quotes[t];
|
||||
if(!q || !q.price) return;
|
||||
card.classList.remove('no-quote');
|
||||
var px=card.querySelector('.px'); px.textContent='$'+q.price.toFixed(2);
|
||||
var ch=card.querySelector('.ch');
|
||||
if(q.day_change_pct==null){ ch.textContent='close '+(q.price_date||''); ch.className='ch flat'; }
|
||||
else if(q.day_change_pct>=0){ ch.textContent='+'+q.day_change_pct.toFixed(2)+'%'; ch.className='ch up'; }
|
||||
else { ch.textContent=q.day_change_pct.toFixed(2)+'%'; ch.className='ch down'; }
|
||||
});
|
||||
}).catch(function(){
|
||||
document.getElementById('bk-meta').textContent='quote fetch failed';
|
||||
});
|
||||
}
|
||||
|
||||
function render(){
|
||||
var host=document.getElementById('result');
|
||||
clearChildren(host);
|
||||
if(!lastRows.length){
|
||||
// Apply ticker filter if set: keep only rows whose tickers include the selected one
|
||||
var pool = tickerFilter ? lastRows.filter(function(r){
|
||||
var ts=(r.tickers&&r.tickers.direct?r.tickers.direct:[]).concat(r.tickers&&r.tickers.associated?r.tickers.associated:[]);
|
||||
return ts.some(function(t){return t.ticker===tickerFilter});
|
||||
}) : lastRows;
|
||||
if(!pool.length){
|
||||
var emp=document.createElement('div'); emp.className='empty';
|
||||
emp.textContent='No contractors match the current filter.';
|
||||
host.appendChild(emp);
|
||||
return;
|
||||
}
|
||||
var rows = lastRows.slice().sort(function(a,b){
|
||||
var rows = pool.slice().sort(function(a,b){
|
||||
var av=a[sortKey], bv=b[sortKey];
|
||||
if(typeof av==='string'){ av=(av||'').toUpperCase(); bv=(bv||'').toUpperCase(); }
|
||||
if(av<bv) return sortDir==='asc'?-1:1;
|
||||
@ -240,6 +366,12 @@ document.getElementById('q').addEventListener('input',function(){
|
||||
});
|
||||
document.getElementById('since').addEventListener('change',load);
|
||||
document.getElementById('min-cost').addEventListener('change',load);
|
||||
document.getElementById('bk-clear').addEventListener('click',function(){
|
||||
tickerFilter=null;
|
||||
document.getElementById('bk-clear').style.display='none';
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.bk-card.selected'), function(c){c.classList.remove('selected')});
|
||||
render();
|
||||
});
|
||||
|
||||
window.addEventListener('load',load);
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user