/** AI Advisor Dashboard with live enrichments and detail drawer */ const { useEffect, useMemo, useState, useRef } = React; const DATA_URL = "./data/dashboard.json"; const BASE = (() => (window.location.pathname.match(/^\/ai-advisor\//) ? "/ai-advisor" : ""))(); const LOCAL_API = `${window.location.protocol}//${window.location.hostname}:8765`; // Optional client-side config for CORS-friendly providers (no backend required) // Provide via window.AIADVISOR_CFG in index.html, or localStorage key 'aiadvisor.cfg' // { iex:{token}, finnhub:{token}, useLocalApi:true, allowLegacy:false, allowYahooFallback:false } const CONFIG = (window.AIADVISOR_CFG || (()=>{ try{ return JSON.parse(localStorage.getItem('aiadvisor.cfg')||'{}'); }catch(e){ return {}; } })()); const QUOTE_ENDPOINTS = [ ...(CONFIG?.allowLegacy ? [`${BASE}/api/quote.php`] : []), ...(CONFIG?.useLocalApi ? [`${LOCAL_API}/quote`] : []), ]; const CHART_ENDPOINTS = [ ...(CONFIG?.allowLegacy ? [`${BASE}/api/chart.php`] : []), ...(CONFIG?.useLocalApi ? [`${LOCAL_API}/chart`] : []), ]; const ANALYSIS_DIR = `./data/analysis`; const REFRESH_MS = 60_000; // auto-refresh interval for quotes const THEME_KEY = "aiadvisor.theme"; /* ---------- formatters ---------- */ const fmt = (n) => (n==null || isNaN(n) ? "—" : Number(n).toLocaleString(undefined,{maximumFractionDigits:2})); const fmtd = (n) => (n==null || isNaN(n) ? "—" : "$"+Number(n).toLocaleString(undefined,{maximumFractionDigits:2})); const fmtpct = (n) => (n==null || isNaN(n) ? "—" : Number(n).toFixed(2)+"%"); const fmtNumber = (n, digits = 0) => (n==null || isNaN(n) ? "—" : Number(n).toLocaleString(undefined,{maximumFractionDigits:digits})); /* ---------- theme ---------- */ function useTheme(){ const [theme,setTheme] = useState(()=>{ const saved = localStorage.getItem(THEME_KEY); if (saved==="light"||saved==="dark") return saved; return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark":"light"; }); useEffect(()=>{ document.documentElement.setAttribute("data-theme",theme); localStorage.setItem(THEME_KEY,theme); },[theme]); return {theme,setTheme}; } /* ---------- dashboard loader ---------- */ function useDashboard(){ const [data,setData] = useState(null); const [err,setErr] = useState(null); const [lastAt,setLastAt] = useState(null); const load = async ()=>{ try{ const r = await fetch(`${DATA_URL}?ts=${Date.now()}`, {cache:"no-store"}); if(!r.ok) throw new Error(`HTTP ${r.status} loading dashboard.json`); const j = await r.json(); setData(j); setLastAt(Date.now()); }catch(e){ console.error("Dashboard load error:", e); setErr(e); } }; useEffect(()=>{ load(); },[]); return {data, setData, err, lastAt, refreshNow: load}; } /* ---------- quotes ---------- */ // Fetch latest quotes + metadata for symbols. Returns { TICKER: { price, changePct, volume } } async function fetchQuotes(symbols){ const uniq = [...new Set((symbols||[]).map(s=>s.toUpperCase()).filter(Boolean))]; if (!uniq.length) return {}; const out = {}; // Polygon snapshot batches (preferred) const polyKey = "5kX56vuJtTKHtLaYz5PT4_4i6eQdxAIG"; if (polyKey){ const chunks = []; for (let i=0;i typeof v === 'number'); if (t && typeof price === 'number'){ out[t] = { price, changePct: typeof q?.regularMarketChangePercent === 'number' ? q.regularMarketChangePercent : null, volume: typeof q?.regularMarketVolume === 'number' ? q.regularMarketVolume : null }; } } } }catch(e){ /* ignore Yahoo CORS errors when disabled */ } } } return out; } /* ---------- apply live prices across all days ---------- */ function applyLivePricesToData(data, priceMap){ if (!data) return data; const clone = JSON.parse(JSON.stringify(data)); const sbd = clone.suggestions_by_day || {}; for (const day of Object.keys(sbd)){ sbd[day] = (sbd[day]||[]).map(rec=>{ const t = (rec.ticker||"").toUpperCase(); const meta = priceMap?.[t] || {}; const live = meta.price != null ? Number(meta.price) : (rec.current_price ?? null); const orig = Number(rec.price); let pct = null; if (orig!=null && live!=null && !isNaN(orig) && !isNaN(live) && orig!==0){ pct = (live/orig - 1)*100.0; } const metrics = {...(rec.metrics||{})}; if (meta.changePct != null) metrics.poly_day_change_pct = meta.changePct; if (meta.volume != null) metrics.poly_day_volume = meta.volume; return {...rec, current_price: live, pct_change: pct, metrics}; }); } clone.suggestions_by_day = sbd; return clone; } /* ---------- small UI helpers ---------- */ const ageClass = (ms)=> (ms < 90_000 ? "ok" : ms < 5*60_000 ? "warn" : "bad"); function HealthDot({ lastAt }){ if(!lastAt) return null; const age=Date.now()-lastAt; return ; } function ThemeToggle({theme,setTheme}){ const next = theme==="dark"?"light":"dark"; return ; } /* ---------- Filters ---------- */ function FilterBar({ value, onChange, onSortChange }){ const update = (patch)=>{ const next = {...value, ...patch}; onChange(next); return next; }; const handleSortChange = (e)=>{ const val = e.target.value; update({sort: val}); if (onSortChange) onSortChange(val); }; return (
Filters
update({search:e.target.value.toUpperCase()})}/>
update({minScore:Number(e.target.value)})}/>
); } function applyFilters(rows, f){ let out = Array.isArray(rows) ? [...rows] : []; if (f.search?.trim()) out = out.filter(r=> (r.ticker||"").toUpperCase().includes(f.search.trim())); if (f.updatesOnly) out = out.filter(r=> r.note==="Update"); out = out.filter(r=> (r.score ?? 0) >= f.minScore); return out; } const SORT_DEFS = { ticker: { type: "string", defaultDir: "asc" }, score: { type: "number", defaultDir: "desc" }, price: { type: "number", defaultDir: "desc" }, current_price: { type: "number", defaultDir: "desc" }, pct_change: { type: "number", defaultDir: "desc" }, stop_loss_price: { type: "number", defaultDir: "desc" }, take_profit_price: { type: "number", defaultDir: "desc" }, note: { type: "string", defaultDir: "asc" }, version: { type: "number", defaultDir: "desc" } }; function sortRows(rows, sort){ if (!Array.isArray(rows)) return []; if (!sort || !sort.key) return [...rows]; const def = SORT_DEFS[sort.key] || { type: "string", defaultDir: "asc" }; const dir = sort.dir === "asc" ? 1 : -1; const accessor = def.accessor || ((r)=> r?.[sort.key]); const copy = [...rows]; copy.sort((a,b)=>{ const va = accessor(a); const vb = accessor(b); const isNullA = va === undefined || va === null || (def.type === "number" && (va === "" || isNaN(Number(va)))); const isNullB = vb === undefined || vb === null || (def.type === "number" && (vb === "" || isNaN(Number(vb)))); if (isNullA && isNullB) return 0; if (isNullA) return 1; if (isNullB) return -1; if (def.type === "number"){ const diff = (Number(va) - Number(vb)) * dir; if (diff === 0) return String(a?.ticker||"").localeCompare(String(b?.ticker||"")); return diff; } const aStr = String(va).toUpperCase(); const bStr = String(vb).toUpperCase(); const cmp = aStr.localeCompare(bStr) * dir; if (cmp === 0) return String(a?.ticker||"").localeCompare(String(b?.ticker||"")); return cmp; }); return copy; } function mapFilterSort(value){ switch(value){ case "ticker": return { key: "ticker", dir: "asc" }; case "delta": return { key: "pct_change", dir: "desc" }; case "score": default: return { key: "score", dir: "desc" }; } } /* ---------- Chart/Analysis loader (details drawer) ---------- */ async function fetchChart(symbol, range="3mo", interval="1d"){ for (const ep of CHART_ENDPOINTS){ const url = `${ep}?symbol=${encodeURIComponent(symbol)}&range=${encodeURIComponent(range)}&interval=${encodeURIComponent(interval)}`; try{ const r = await fetch(url, {cache:"no-store"}); if (r.ok){ return await r.json(); } }catch(e){ /* try next */ } } // Polygon fallback const apiKey = CONFIG?.polygon?.apiKey; if (apiKey){ try{ const now = new Date(); const start = new Date(now); if (range === '6mo') start.setMonth(start.getMonth()-6); else if (range==='1y') start.setFullYear(start.getFullYear()-1); else start.setMonth(start.getMonth()-3); const from = start.getTime(); const to = now.getTime(); const url = `https://api.polygon.io/v2/aggs/ticker/${encodeURIComponent(symbol)}/range/1/day/${from}/${to}?adjusted=true&sort=asc&limit=50000&apiKey=${encodeURIComponent(apiKey)}`; const r = await fetch(url, {cache:'no-store'}); if (r.ok){ const j = await r.json(); const res = Array.isArray(j?.results) ? j.results : []; const t = []; const c = []; for (const it of res){ const ts = typeof it?.t === 'number' ? Math.floor(it.t/1000) : null; const close = typeof it?.c === 'number' ? it.c : null; if (ts && close != null){ t.push(ts); c.push(close); } } return { t, c }; } }catch{} } return { t: [], c: [] }; } async function fetchAnalysis(symbol){ const sym = String(symbol||"").toUpperCase(); if (!sym) return null; const url = `${ANALYSIS_DIR}/${encodeURIComponent(sym)}.json?ts=${Date.now()}`; try{ const r = await fetch(url, {cache:"no-store"}); if(!r.ok) return null; return await r.json(); }catch{ return null; } } function MiniChart({ data, id="miniChart" }){ const ref = useRef(null); useEffect(()=>{ if(!data || !data.t || !data.c || !data.c.length) return; const ctx = ref.current.getContext("2d"); const labels = data.t.map(ts => new Date(ts*1000).toLocaleDateString()); // Destroy previous instance if any if (ref.current._chart) { ref.current._chart.destroy(); } ref.current._chart = new Chart(ctx,{ type:'line', data:{ labels, datasets:[{ label:'Close', data: data.c }] }, options:{ responsive:true, plugins:{ legend:{ display:false } }, elements:{ point:{ radius:0 } }, scales:{ x:{ ticks:{ maxTicksLimit:6, color:getComputedStyle(document.documentElement).getPropertyValue("--mut") } }, y:{ ticks:{ color:getComputedStyle(document.documentElement).getPropertyValue("--mut") } } } } }); return ()=>{ if (ref.current && ref.current._chart) ref.current._chart.destroy(); }; },[data]); return ; } function ProChart({ analysis, record }){ const containerRef = useRef(null); const chartRef = useRef(null); const [tf, setTf] = useState('daily'); const [ov, setOv] = useState({ vol:true, ema:true, sma:true, bb:true, keltner:true, donch:true, sr:true, patterns:true, sltp:true }); const annModeRef = useRef(null); const annTmpRef = useRef(null); const annSeriesRef = useRef([]); useEffect(()=>{ const root = containerRef.current; if (!root || !window.LightweightCharts) return; if (chartRef.current) { try{ chartRef.current.remove(); }catch{} chartRef.current=null; } const data = analysis?.chart_data?.[tf] || analysis?.chart_data?.daily || []; if (!Array.isArray(data) || !data.length) return; const { createChart } = window.LightweightCharts; const cs = getComputedStyle(document.documentElement); const textColor = cs.getPropertyValue('--mut') || '#9aa4b2'; const chart = createChart(root, { width: root.clientWidth, height: 280, layout: { background: { type: 'solid', color: 'transparent' }, textColor }, rightPriceScale: { borderVisible:false }, timeScale: { borderVisible:false }, grid: { vertLines: { color: 'rgba(0,0,0,0.2)' }, horzLines: { color: 'rgba(0,0,0,0.2)' } } }); chartRef.current = chart; const candle = chart.addCandlestickSeries({ upColor:'#0bd199', downColor:'#e11d48', borderVisible:false, wickUpColor:'#0bd199', wickDownColor:'#e11d48' }); const times = data.map(d=> Math.floor(new Date(d.time).getTime()/1000)); candle.setData(data.map((d,i)=>({ time: times[i], open:d.open, high:d.high, low:d.low, close:d.close }))); // Volume histogram on separate scale margin if (ov.vol){ const vol = chart.addHistogramSeries({ priceScaleId: 'vol', color:'#94a3b8', base: 0 }); vol.setData(data.map((d,i)=>({ time: times[i], value: (d.volume||0), color: (d.close>=d.open ? '#34d39980' : '#f8717180') }))); chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } }); } const addLine = (seriesPts, color, width=1)=>{ if (!Array.isArray(seriesPts) || !seriesPts.length) return null; const s = chart.addLineSeries({ color, lineWidth: width, priceLineVisible:false }); s.setData(seriesPts.map(p=>({ time: Math.floor(new Date(p.time).getTime()/1000), value:p.value }))); return s; }; const ind = analysis?.indicators || {}; if (ov.ema){ addLine(ind.ema9, '#8ab4ff', 1); addLine(ind.ema21, '#ffd666', 1); } if (ov.sma){ addLine(ind.sma50, '#f59e0b', 1); addLine(ind.sma200,'#94a3b8', 1); } if (ov.bb){ addLine(ind.bb_upper, '#86efac', 1); addLine(ind.bb_middle,'#86efac66', 1); addLine(ind.bb_lower, '#86efac', 1); } if (ov.keltner){ addLine(ind.keltner_upper, '#60a5fa', 1); addLine(ind.keltner_lower, '#60a5fa', 1); } if (ov.donch){ addLine(ind.donchian_high, '#f59e0b66', 1); addLine(ind.donchian_low, '#f59e0b66', 1); } if (ov.sr){ const levels = analysis?.support_resistance || []; for (const lv of levels){ const y = Number(lv?.level); if (!y || Number.isNaN(y)) continue; const lc = lv?.type==='support' ? '#34d399' : '#f87171'; const width = Math.max(1, Math.min(4, Number(lv?.strength)||1)); const s = chart.addLineSeries({ color: lc, lineWidth:width, priceLineVisible:false }); const first = times[0]; const last = times[times.length-1]; s.setData([{ time:first, value:y }, { time:last, value:y }]); } } // Stop/Target overlays from current record if present if (ov.sltp){ const sl = Number(record?.stop_loss_price); const tp = Number(record?.take_profit_price); if (sl && !Number.isNaN(sl)){ const s = chart.addLineSeries({ color:'#ef4444', lineWidth:1, lineStyle:2, priceLineVisible:false }); const first = times[0]; const last = times[times.length-1]; s.setData([{ time:first, value:sl }, { time:last, value:sl }]); } if (tp && !Number.isNaN(tp)){ const s = chart.addLineSeries({ color:'#22c55e', lineWidth:1, lineStyle:2, priceLineVisible:false }); const first = times[0]; const last = times[times.length-1]; s.setData([{ time:first, value:tp }, { time:last, value:tp }]); } } if (ov.patterns){ const pats = Array.isArray(analysis?.patterns) ? analysis.patterns : []; for (const p of pats){ const pts = Array.isArray(p?.points) ? p.points : []; if (pts.length >= 2){ const ls = chart.addLineSeries({ color:'#eab308', lineWidth:1, priceLineVisible:false }); ls.setData(pts.map(pt=>({ time: Math.floor(new Date(pt.time).getTime()/1000), value: pt.value }))); } } } const onResize = ()=>{ chart.applyOptions({ width: root.clientWidth }); }; const ro = new ResizeObserver(onResize); ro.observe(root); // annotation clicks const onClick = (param)=>{ const mode = annModeRef.current; if (!mode) return; if (param && param.time != null && param.point){ const t = Math.floor(Number(param.time)); const price = candle.coordinateToPrice(param.point.y); if (mode === 'hline'){ const s = chart.addLineSeries({ color:'#93c5fd', lineStyle:2, lineWidth:1, priceLineVisible:false }); s.setData([{ time: times[0], value: price }, { time: times[times.length-1], value: price }]); annSeriesRef.current.push(s); annModeRef.current = null; } else if (mode === 'trend'){ if (!annTmpRef.current){ annTmpRef.current = { t, price }; } else { const p1 = annTmpRef.current; const p2 = { t, price }; const s = chart.addLineSeries({ color:'#93c5fd', lineWidth:1, priceLineVisible:false }); s.setData([ { time: Math.min(p1.t, p2.t), value: p1.t <= p2.t ? p1.price : p2.price }, { time: Math.max(p1.t, p2.t), value: p1.t <= p2.t ? p2.price : p1.price } ]); annSeriesRef.current.push(s); annTmpRef.current = null; annModeRef.current = null; } } } }; chart.subscribeClick(onClick); return ()=>{ try{ ro.disconnect(); }catch{} if(chartRef.current){ try{ chartRef.current.remove(); }catch{} chartRef.current=null; } }; }, [analysis, tf, ov]); const hasIntraday = Array.isArray(analysis?.chart_data?.intraday) && analysis.chart_data.intraday.length > 0; return ( <> {hasIntraday && (
Timeframe
)}
{[ ['vol','Vol'],['ema','EMA'],['sma','SMA'],['bb','BB'],['keltner','Keltner'],['donch','Donchian'],['sr','S/R'],['patterns','Patterns'],['sltp','SL/TP'] ].map(([k,label])=> ( ))}
); } function IndicatorsPanel({ analysis }){ const ind = analysis?.indicators || {}; const last = (arr)=> (Array.isArray(arr) && arr.length ? arr[arr.length-1]?.value : null); const rows = [ { label:'EMA9', value:last(ind.ema9) }, { label:'EMA21', value:last(ind.ema21) }, { label:'SMA50', value:last(ind.sma50) }, { label:'SMA200', value:last(ind.sma200) }, { label:'RSI14', value:last(ind.rsi14) }, { label:'ATR14', value:last(ind.atr14) }, { label:'BB Upper', value:last(ind.bb_upper) }, { label:'BB Middle', value:last(ind.bb_middle) }, { label:'BB Lower', value:last(ind.bb_lower) }, { label:'Keltn Upper', value:last(ind.keltner_upper) }, { label:'Keltn Lower', value:last(ind.keltner_lower) }, { label:'Donch High', value:last(ind.donchian_high) }, { label:'Donch Low', value:last(ind.donchian_low) } ]; return (
Indicators (latest)
{rows.map((r,i)=> (
{r.label}
{fmt(r.value)}
))}
); } function PatternsPanel({ analysis }){ const pats = analysis?.patterns || []; const levels = analysis?.support_resistance || []; const drv = analysis?.derived || {}; return (
Patterns & Levels
{!pats.length && !levels.length ?
No detections
: null} {pats.slice(0,3).map((p,idx)=> (
{p.type} · {p.direction} · conf {fmtpct((p.confidence||0)*100)}
Target {fmtd(p.target_price)} · Stop {fmtd(p.stop_price)}
))} {!!levels.length && (
Top Levels
{levels.slice(0,6).map((lv, i)=> (
{lv.type}: {fmtd(lv.level)} · strength {fmtNumber(lv.strength,0)}
))}
)} {drv && (
Derived
BB Position: {fmtNumber(drv.bb_pos,2)}
Keltner Upper Dist: {fmtpct(drv.keltner_upper_dist_pct)}
Keltner Lower Dist: {fmtpct(drv.keltner_lower_dist_pct)}
Nearest {drv.sr_nearest_type || '-'}: {fmtd(drv.sr_nearest_level)} ({fmtpct(drv.sr_nearest_dist_pct)})
)}
); } function SubChartRSI({ analysis }){ const ref = useRef(null); const chartRef = useRef(null); useEffect(()=>{ const root = ref.current; if (!root || !window.LightweightCharts) return; if (chartRef.current){ try{ chartRef.current.remove(); }catch{} chartRef.current=null; } const data = analysis?.indicators?.rsi14 || []; if (!Array.isArray(data) || !data.length) return; const { createChart } = window.LightweightCharts; const cs = getComputedStyle(document.documentElement); const textColor = cs.getPropertyValue('--mut') || '#9aa4b2'; const chart = createChart(root, { width: root.clientWidth, height: 160, layout:{ background:{type:'solid', color:'transparent'}, textColor }, rightPriceScale:{ borderVisible:false }, timeScale:{ borderVisible:false }, grid:{ vertLines:{ color:'rgba(0,0,0,0.2)'}, horzLines:{ color:'rgba(0,0,0,0.2)'} } }); chartRef.current = chart; const s = chart.addLineSeries({ color:'#a78bfa', lineWidth:1, priceLineVisible:false }); const pts = data.map(p=>({ time: Math.floor(new Date(p.time).getTime()/1000), value: p.value })); s.setData(pts); // 70/30 reference const t0 = pts[0]?.time, t1 = pts[pts.length-1]?.time; const ovr = chart.addLineSeries({ color:'#ef4444', lineWidth:1, priceLineVisible:false }); ovr.setData([{time:t0, value:70},{time:t1, value:70}]); const und = chart.addLineSeries({ color:'#22c55e', lineWidth:1, priceLineVisible:false }); und.setData([{time:t0, value:30},{time:t1, value:30}]); const ro = new ResizeObserver(()=> chart.applyOptions({width: root.clientWidth})); ro.observe(root); return ()=>{ try{ ro.disconnect(); }catch{} if(chartRef.current){ try{ chartRef.current.remove(); }catch{} chartRef.current=null; } }; }, [analysis]); return
; } function SubChartMACD({ analysis }){ const ref = useRef(null); const chartRef = useRef(null); useEffect(()=>{ const root = ref.current; if (!root || !window.LightweightCharts) return; if (chartRef.current){ try{ chartRef.current.remove(); }catch{} chartRef.current=null; } const macd = analysis?.indicators?.macd || []; const sig = analysis?.indicators?.macd_signal || []; const hist = analysis?.indicators?.macd_hist || []; if (!Array.isArray(macd) || !macd.length) return; const { createChart } = window.LightweightCharts; const cs = getComputedStyle(document.documentElement); const textColor = cs.getPropertyValue('--mut') || '#9aa4b2'; const chart = createChart(root, { width: root.clientWidth, height: 160, layout:{ background:{type:'solid', color:'transparent'}, textColor }, rightPriceScale:{ borderVisible:false }, timeScale:{ borderVisible:false }, grid:{ vertLines:{ color:'rgba(0,0,0,0.2)'}, horzLines:{ color:'rgba(0,0,0,0.2)'} } }); chartRef.current = chart; const times = macd.map(p=> Math.floor(new Date(p.time).getTime()/1000)); const l1 = chart.addLineSeries({ color:'#60a5fa', lineWidth:1, priceLineVisible:false }); l1.setData(macd.map((p,i)=>({ time:times[i], value:p.value }))); if (Array.isArray(sig) && sig.length){ const l2 = chart.addLineSeries({ color:'#f59e0b', lineWidth:1, priceLineVisible:false }); const t2 = sig.map(p=> Math.floor(new Date(p.time).getTime()/1000)); l2.setData(sig.map((p,i)=>({ time:t2[i], value:p.value }))); } if (Array.isArray(hist) && hist.length){ const h = chart.addHistogramSeries({ base:0 }); const th = hist.map(p=> Math.floor(new Date(p.time).getTime()/1000)); h.setData(hist.map((p,i)=>({ time: th[i], value: p.value, color: (p.value>=0 ? '#34d39980' : '#f8717180') }))); } const ro = new ResizeObserver(()=> chart.applyOptions({width: root.clientWidth})); ro.observe(root); return ()=>{ try{ ro.disconnect(); }catch{} if(chartRef.current){ try{ chartRef.current.remove(); }catch{} chartRef.current=null; } }; }, [analysis]); return
; } function VersionHistory({ versions }){ if (!Array.isArray(versions) || !versions.length) return null; const sorted = [...versions].sort((a,b)=>{ const da = (a.date || "").toString(); const db = (b.date || "").toString(); const cmp = da.localeCompare(db); if (cmp !== 0) return cmp; return (a.version||0) - (b.version||0); }); return (
Version History
{sorted.map((v, idx)=>{ const ver = v.version ?? idx + 1; const dateLabel = v.date ? String(v.date) : null; return (
v{ver}
Price {fmtd(v.price)}{dateLabel ? ` · ${dateLabel}` : ''}
Stop {v.stop_loss_price ? fmtd(v.stop_loss_price) : '—'} · Target {v.take_profit_price ? fmtd(v.take_profit_price) : '—'} · Score {(v.score ?? 0)+'%'}{v.note ? ` · ${v.note}` : ''}
{v.reason ?
{v.reason}
: null}
); })}
); } /* ---------- Detail Drawer ---------- */ function DetailDrawer({ open, onClose, record }){ const [loading,setLoading] = useState(false); const [analysis,setAnalysis] = useState(null); const [mini,setMini] = useState(null); const [tab,setTab] = useState('overview'); const [live, setLive] = useState(null); useEffect(()=>{ if (!open || !record?.ticker) return; setLoading(true); setAnalysis(null); setMini(null); setLive(null); const embedded = record?.analysis; const apply = (a)=>{ setAnalysis(a); const daily = a?.chart_data?.daily || []; if (daily.length){ const t = []; const c = []; for (const p of daily){ t.push(Math.floor(new Date(p.time).getTime()/1000)); c.push(p.close); } setMini({ t, c }); } }; if (embedded && typeof embedded === 'object'){ apply(embedded); setLoading(false); return; } fetchAnalysis(record.ticker).then(a=>{ if(a){ apply(a); return; } // Fallback to legacy chart endpoint for mini only return fetchChart(record.ticker).then(j=> setMini(j)).catch(()=> setMini(null)); }).finally(()=> setLoading(false)); },[open, record?.ticker]); // Freshen per-symbol metrics client-side via Polygon (preferred), then IEX/Finnhub useEffect(()=>{ if (!open || !record?.ticker) return; let abort = false; (async()=>{ const sym = record.ticker; // Polygon snapshot + short interest + recent news counts const polyKey = CONFIG?.polygon?.apiKey; if (polyKey){ try{ const [snapR, siR, newsR] = await Promise.all([ fetch(`https://api.polygon.io/v2/snapshot/locale/us/markets/stocks/tickers/${encodeURIComponent(sym)}?apiKey=${encodeURIComponent(polyKey)}`, {cache:'no-store'}), fetch(`https://api.polygon.io/v3/reference/short-interest?ticker=${encodeURIComponent(sym)}&limit=1&order=desc&apiKey=${encodeURIComponent(polyKey)}`, {cache:'no-store'}), (async()=>{ const since7 = new Date(Date.now() - 7*24*3600*1000).toISOString(); const url = `https://api.polygon.io/v2/reference/news?ticker=${encodeURIComponent(sym)}&published_utc.gte=${encodeURIComponent(since7)}&order=desc&limit=50&apiKey=${encodeURIComponent(polyKey)}`; return fetch(url, {cache:'no-store'}); })() ]); let price=null, changePct=null, volume=null, shortPctFloat=null, daysToCover=null, news3d=0, newsSent=0; if (snapR.ok){ const j = await snapR.json(); const t = j?.ticker; price = (t?.lastTrade?.p ?? t?.day?.c ?? t?.prevDay?.c) ?? null; changePct = (typeof t?.todaysChangePerc === 'number' ? t.todaysChangePerc : null); volume = (typeof t?.day?.v === 'number' ? t.day.v : null); } if (siR.ok){ const j = await siR.json(); const last = Array.isArray(j?.results) ? j.results[0] : null; if (last){ shortPctFloat = (typeof last?.short_interest_share_percent === 'number' ? last.short_interest_share_percent : null); daysToCover = (typeof last?.days_to_cover === 'number' ? last.days_to_cover : (typeof last?.dsi === 'number' ? last.dsi : null)); } } if (newsR.ok){ const j = await newsR.json(); const items = Array.isArray(j?.results) ? j.results : []; const cutoff3 = Date.now() - 3*24*3600*1000; const kwPos = ["beat","beats","record","raises","upgrade","surge","growth","profit","guidance up","launch","contract","wins"]; const kwNeg = ["miss","misses","downgrade","cut","guidance down","lawsuit","probe","recall","delay","bankruptcy","sec charges","fda halt"]; for (const it of items){ const ts = Date.parse(it?.published_utc || it?.timestamp || 0); if (!isNaN(ts) && ts >= cutoff3) news3d++; const title = String(it?.title||'').toLowerCase(); let s=0; for (const w of kwPos) if (title.includes(w)) s++; for (const w of kwNeg) if (title.includes(w)) s--; newsSent += s; } } if (!abort) setLive({ price, changePct, volume, shortPctFloat, daysToCover, news_count_3d: news3d, news_sent_sum: newsSent }); return; }catch{} } // IEX const iexToken = CONFIG?.iex?.token; if (iexToken){ try{ const url = `https://cloud.iexapis.com/stable/stock/${encodeURIComponent(sym)}/quote?token=${encodeURIComponent(iexToken)}`; const r = await fetch(url, {cache:'no-store'}); if (r.ok){ const q = await r.json(); const payload = { price: (typeof q?.latestPrice === 'number' ? q.latestPrice : null), changePct: (typeof q?.changePercent === 'number' ? q.changePercent * 100 : null), volume: (typeof q?.latestVolume === 'number' ? q.latestVolume : null), shortPctFloat: null, daysToCover: null }; if (!abort) setLive(payload); return; } }catch{} } // Finnhub const finnhubToken = CONFIG?.finnhub?.token; if (finnhubToken){ try{ const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(sym)}&token=${encodeURIComponent(finnhubToken)}`; const r = await fetch(url, {cache:'no-store'}); if (r.ok){ const q = await r.json(); const payload = { price: (typeof q?.c === 'number' ? q.c : null), changePct: (typeof q?.dp === 'number' ? q.dp : null), volume: null, // not in /quote shortPctFloat: null, daysToCover: null }; if (!abort) setLive(payload); return; } }catch{} } // Fallback to local API if explicitly enabled if (CONFIG?.useLocalApi){ try{ const url = `${LOCAL_API}/metrics?symbol=${encodeURIComponent(sym)}`; const r = await fetch(url, {cache:'no-store'}); if (r.ok){ const j = await r.json(); if (!abort) setLive(j); } }catch{} } })(); return ()=>{ abort = true; }; }, [open, record?.ticker]); useEffect(()=>{ const onKey = (e)=>{ if (e.key==="Escape") onClose(); }; if (open) document.addEventListener("keydown", onKey); return ()=> document.removeEventListener("keydown", onKey); },[open,onClose]); if (!open || !record) return null; const currentPx = (typeof live?.price === 'number') ? live.price : record.current_price; const pctNow = (typeof currentPx === 'number' && typeof record.price === 'number' && record.price !== 0) ? ((currentPx/record.price - 1) * 100) : record.pct_change; const dir = Number(pctNow)>=0 ? "pos":"neg"; let chartPlan = null; if (record.chart_plan){ if (typeof record.chart_plan === 'string'){ try { chartPlan = JSON.parse(record.chart_plan); } catch { chartPlan = { notes: record.chart_plan }; } } else if (typeof record.chart_plan === 'object'){ chartPlan = record.chart_plan; } } const planTimeframes = Array.isArray(chartPlan?.timeframes) ? chartPlan.timeframes : []; const planOverlays = Array.isArray(chartPlan?.overlays) ? chartPlan.overlays : []; const planAnnotations = Array.isArray(chartPlan?.annotations) ? chartPlan.annotations : []; return (
{record.ticker}
Original
{fmtd(record.price)}
Current
{fmtd(currentPx)}
Change %
{fmtpct(pctNow)}
Score
{(record.score??0)+'%'}
Stop
{record.stop_loss_price? fmtd(record.stop_loss_price) : "--"}
Target
{record.take_profit_price? fmtd(record.take_profit_price) : "--"}
{tab==='overview' && (
Price (last 3 months)
{loading ?
Loading chart...
: }
)} {tab==='pro' && (
Pro Chart
{!analysis ?
No analysis available.
: }
)} {tab==='ind' && ( )} {tab==='pat' && ( )} {record.metrics ? (
Latest Metrics
News Count (3d)
{(live?.news_count_3d != null) ? fmtNumber(live.news_count_3d) : fmtNumber(record.metrics.news_count_3d)}
News Sentiment
{(live?.news_sent_sum != null) ? fmtNumber(live.news_sent_sum) : fmtNumber(record.metrics.news_sent_sum)}
Day Change %
{(live?.changePct != null) ? fmtpct(live.changePct) : (record.metrics.poly_day_change_pct != null ? fmtpct(record.metrics.poly_day_change_pct) : "--")}
Day Volume
{(live?.volume != null) ? fmtNumber(live.volume) : (record.metrics.poly_day_volume != null ? fmtNumber(record.metrics.poly_day_volume) : "--")}
Short Interest
{(live?.shortPctFloat != null) ? fmtNumber(live.shortPctFloat, 2) : (record.metrics.poly_short_interest != null ? fmtNumber(record.metrics.poly_short_interest, 2) : "--")}
Days to Cover
{(live?.daysToCover != null) ? fmtNumber(live.daysToCover, 2) : (record.metrics.poly_days_to_cover != null ? fmtNumber(record.metrics.poly_days_to_cover, 2) : "--")}
): null} {chartPlan ? (
AI Chart Plan
{planTimeframes.length ? (
Timeframes: {planTimeframes.join(', ')}
) : null} {planOverlays.length ? (
Overlays
    {planOverlays.map((ov, idx)=>(
  • {ov?.type || 'overlay'}{ov?.params ? ` · ${JSON.stringify(ov.params)}` : ''}{ov?.why ? ` — ${ov.why}` : ''}
  • ))}
) : null} {planAnnotations.length ? (
Annotations
    {planAnnotations.map((ann, idx)=>(
  • {ann?.label || ann?.type || `Level ${idx+1}`}{ann?.price != null ? ` @ ${fmtd(ann.price)}` : ''}{ann?.context ? ` — ${ann.context}` : ''}
  • ))}
) : null} {chartPlan?.notes ? (
{chartPlan.notes}
) : null}
) : null} {record.reason ? (
Why this was suggested
{record.reason}
): null} {Array.isArray(record.news) && record.news.length ? (
Latest News
): null}
); } /* ---------- Hero + Tables ---------- */ function HeroRow({ title, items, onPick }){ if(!items || !items.length) return null; return (
{title}
{items.map((r,i)=>{ const dir = Number(r.pct_change)>=0 ? "pos":"neg"; return ( ); })}
); } function SuggestionsTable({ title, items, onPick, onSort, sortState }){ const rows = Array.isArray(items) ? items : []; const columns = [ { key: "ticker", label: "Ticker", sortable: true }, { key: "score", label: "Score", sortable: true }, { key: "price", label: "Original", sortable: true }, { key: "current_price", label: "Current", sortable: true }, { key: "pct_change", label: "Change %", sortable: true }, { key: "stop_loss_price", label: "Stop", sortable: true }, { key: "take_profit_price", label: "Target", sortable: true }, { key: "note", label: "Note", sortable: true }, { key: "version", label: "Rev", sortable: true } ]; const handleSort = (key)=>{ if (onSort) onSort(key); }; return (
{title ?
{title}
: null}
{columns.map(col=>{ const isActive = sortState?.key === col.key; const dir = isActive ? sortState?.dir : null; const sortAria = isActive ? (dir === "asc" ? "ascending" : "descending") : "none"; const btnClasses = ['sort-btn']; if (isActive){ btnClasses.push('active'); if (dir) btnClasses.push(dir); } return ( ); })} {rows.length ? rows.map((r,i)=>{ const cls = Number(r.pct_change)>=0 ? "pos" : "neg"; const badge = r.note==='Update' ? Update : New; return ( onPick(r)} style={{cursor:"pointer"}}> ); }) : ( )}
{col.sortable && onSort ? ( ) : col.label}
{r.ticker} {(r.score ?? 0)+'%'} {fmtd(r.price)} {fmtd(r.current_price)} {fmtpct(r.pct_change)} {r.stop_loss_price ? fmtd(r.stop_loss_price) : '-'} {r.take_profit_price ? fmtd(r.take_profit_price) : '-'} {badge} {r.version ?? 1}
No suggestions.
); } function DayBrowser({ suggestionsByDay, onPick, active }){ const [q,setQ] = useState(""); const daysAll = useMemo(()=>Object.keys(suggestionsByDay||{}).sort((a,b)=>b.localeCompare(a)),[suggestionsByDay]); const days = useMemo(()=>daysAll.filter(d=>d.includes(q.trim())),[q,daysAll]); return (
History
setQ(e.target.value)} />
{days.map(d=> )}
); } /* ---------- App ---------- */ function App(){ const {theme,setTheme} = useTheme(); const {data, setData, err, lastAt, refreshNow} = useDashboard(); const [activeDay,setActiveDay] = useState(null); const [loadingQuotes,setLoadingQuotes] = useState(false); const [drawer,setDrawer] = useState({open:false, record:null}); const suggestionsByDay = data?.suggestions_by_day || {}; const days = useMemo(()=>Object.keys(suggestionsByDay).sort((a,b)=>b.localeCompare(a)),[suggestionsByDay]); const latest = days[0] || null; const active = activeDay || latest; const latestData = suggestionsByDay[latest] || []; const activeData = suggestionsByDay[active] || []; // Top-5 const todayTop5 = useMemo(()=>[...latestData].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,5),[latestData]); const allRecords = useMemo(()=>{ const recs=[]; for(const d of Object.keys(suggestionsByDay)){ for(const r of suggestionsByDay[d]) recs.push({...r, date:d}); } return recs; },[suggestionsByDay]); const topAllTime = useMemo(()=>[...allRecords] .filter(r=>typeof r.pct_change==="number" && !isNaN(r.pct_change)) .sort((a,b)=>(b.pct_change||-1e9)-(a.pct_change||-1e9)) .slice(0,5),[allRecords]); // Collect all tickers for live quotes const allTickers = useMemo(()=>{ if(!data) return []; const arr=[]; for(const d of Object.keys(suggestionsByDay)){ for(const r of suggestionsByDay[d]) if(r?.ticker) arr.push(r.ticker.toUpperCase()); } return [...new Set(arr)]; },[data, suggestionsByDay]); // Load quotes once after dashboard.json arrives const refreshPrices = async ()=>{ if (!data || !allTickers.length) return; const canLive = !!(CONFIG?.polygon?.apiKey || CONFIG?.iex?.token || CONFIG?.finnhub?.token || (QUOTE_ENDPOINTS && QUOTE_ENDPOINTS.length) || CONFIG?.allowYahooFallback); if (!canLive) return; setLoadingQuotes(true); try{ const priceMap = await fetchQuotes(allTickers); if (priceMap && Object.keys(priceMap).length){ const next = applyLivePricesToData(data, priceMap); setData(next); } }catch(e){ console.warn('refreshPrices failed:', e); }finally{ setLoadingQuotes(false); } }; useEffect(()=>{ if (data) refreshPrices(); },[data]); // Optional periodic auto-refresh useEffect(()=>{ const canLive = !!(CONFIG?.polygon?.apiKey || CONFIG?.iex?.token || CONFIG?.finnhub?.token || (QUOTE_ENDPOINTS && QUOTE_ENDPOINTS.length) || CONFIG?.allowYahooFallback); if (!REFRESH_MS || !canLive) return; const id = setInterval(()=>{ refreshPrices(); }, REFRESH_MS); return ()=> clearInterval(id); },[allTickers, data]); // Filters for tables const [filters,setFilters] = useState({search:"", minScore:0, updatesOnly:false, sort:"score"}); const [tableSort,setTableSort] = useState(()=>mapFilterSort('score')); const latestFiltered = useMemo(()=>{ const filtered = applyFilters(latestData, filters); return sortRows(filtered, tableSort); },[latestData, filters, tableSort]); const activeFiltered = useMemo(()=>{ const filtered = applyFilters(activeData, filters); return sortRows(filtered, tableSort); },[activeData, filters, tableSort]); const handleSortSelect = (value)=>{ setTableSort(mapFilterSort(value)); }; const handleColumnSort = (key)=>{ setTableSort(prev=>{ const current = prev || {}; const def = SORT_DEFS[key] || { defaultDir: 'asc' }; const defaultDir = def.defaultDir || 'asc'; const nextDir = current.key === key ? (current.dir === 'asc' ? 'desc' : 'asc') : defaultDir; return { key, dir: nextDir }; }); const dropdownMap = { score: 'score', pct_change: 'delta', ticker: 'ticker' }; if (dropdownMap[key]){ setFilters(prev => prev.sort === dropdownMap[key] ? prev : {...prev, sort: dropdownMap[key]}); } }; const handleSetPolygonKey = ()=>{ const existing = CONFIG?.polygon?.apiKey || ''; const entered = window.prompt('Enter Polygon API key (stored locally in your browser). Leave blank to remove.', existing); if (entered === null) return; const trimmed = entered.trim(); const nextCfg = {...CONFIG}; if (trimmed){ nextCfg.polygon = { apiKey: trimmed }; } else { if (nextCfg.polygon) delete nextCfg.polygon; } localStorage.setItem('aiadvisor.cfg', JSON.stringify(nextCfg)); window.AIADVISOR_CFG = nextCfg; window.location.reload(); }; const openDetails = (rec)=> setDrawer({open:true, record:rec}); const closeDetails = ()=> setDrawer({open:false, record:null}); if (err) return
Error loading dashboard.json.
; if (!data) return
Loading...
; return ( <>
AI Advisor
Suggestions
Data {lastAt ? new Date(lastAt).toLocaleTimeString() : "--"}
{(() => { const src = CONFIG?.polygon?.apiKey ? 'Polygon' : (CONFIG?.iex?.token ? 'IEX' : (CONFIG?.finnhub?.token ? 'Finnhub' : null)); const canLive = !!src || (QUOTE_ENDPOINTS && QUOTE_ENDPOINTS.length) || CONFIG?.allowYahooFallback; const label = canLive ? `${loadingQuotes ? 'updating...' : 'live'}${src ? ` · ${src}` : ''}` : 'off · add Polygon API key'; return
| Prices {label}
; })()}
{!CONFIG?.polygon?.apiKey ? : null}
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();