/* scanner.jsx — Release 1 "scanner senza IA" (29/10/2026). Termometro di mercato + tabella di ~1.500 titoli USA: posizione nei range (giorno prima, settimana, 2 settimane, mese, 4 mesi, 6 mesi, 52 settimane), medie 20/50/100/200, volume, earnings. Dettaglio con notizie e motivazione. Dati: GET /api/scanner (compatto) e /api/scanner/{symbol}. Stellina → watchlist live. */ (function () { const { useState, useEffect, useMemo } = React; const RANGES = [["1d", "1g"], ["1w", "1s"], ["2w", "2s"], ["1m", "1m"], ["4m", "4m"], ["6m", "6m"], ["52w", "52s"]]; const RANGE_LABEL = { "1d": "giorno prima", "1w": "settimana", "2w": "2 settimane", "1m": "mese", "4m": "4 mesi", "6m": "6 mesi", "52w": "52 settimane" }; const MAS = [20, 50, 100, 200]; const PAGE = 100; const fmtPct = (v, d = 1) => v == null ? "—" : `${v >= 0 ? "+" : ""}${v.toFixed(d)}%`; const fmtPx = v => v == null ? "—" : (v >= 10 ? v.toFixed(2) : v.toFixed(3)); const pctCls = v => v == null ? "" : v > 0 ? "sc-up" : v < 0 ? "sc-down" : ""; function Lev3({ v }) { // variazione con l'equivalente a leva 3x in grassetto if (v == null) return —; return {fmtPct(v)} ×3 {fmtPct(v * 3)}; } function RangeBar({ pos, bhi, blo, label }) { const p = pos == null ? null : Math.max(0, Math.min(100, pos)); const cls = bhi ? "up" : blo ? "down" : ""; return ( {p != null && } ); } function MaDots({ r }) { return ( {MAS.map(m => { const d = r[`dma${m}`], x = r[`xma${m}`]; const cls = d == null ? "na" : d >= 0 ? "above" : "below"; return = 0 ? "sopra" : "sotto") + " " + fmtPct(d)}${x ? (x > 0 ? " · incrocio al rialzo oggi" : " · incrocio al ribasso oggi") : ""}`} />; })} ); } const near = (r, side) => { if (!r.atr14 || r.close == null) return false; if (side === "hi") return !r.bhi_52w && r.nhi_52w != null && r.nhi_52w - r.close > 0 && r.nhi_52w - r.close < r.atr14; return !r.blo_52w && r.nlo_52w != null && r.close - r.nlo_52w > 0 && r.close - r.nlo_52w < r.atr14; }; const PRESETS = [ ["tutti", "Tutti"], ["max52", "Massimo 52 sett. (rotto o vicino)"], ["min52", "Minimo 52 sett. (rotto o vicino)"], ["rotture", "Rotture di oggi"], ["sopra200", "Sopra media 200"], ["sotto200", "Sotto media 200"], ["incroci", "Incroci medie oggi"], ["stellati", "★ Seguiti"], ]; function Thermometer({ market, date, spy, qqq }) { const m = market[market.length - 1] || {}; const hist = market.slice(-60).map(x => (x.nh_52w || 0) - (x.nl_52w || 0)); const w = 180, h = 34; const mx = Math.max(1, ...hist.map(Math.abs)); const pts = hist.map((v, i) => `${(i / Math.max(1, hist.length - 1)) * w},${h / 2 - (v / mx) * (h / 2 - 2)}`).join(" "); return (
Seduta{date ? date.split("-").reverse().join("/") : "—"}
SPY
QQQ
Nuovi max / min 52 sett. {m.nh_52w ?? "—"} / {m.nl_52w ?? "—"}
Sopra media 200{m.above_ma200 != null ? `${m.above_ma200.toFixed(0)}%` : "—"}
Su / giù oggi{m.adv ?? "—"} / {m.decl ?? "—"}
{hist.length > 2 && (
Termometro 60 gg
)}
); } function PriceChart({ history, row }) { if (!history || history.length < 2) return null; const w = 560, h = 150, vals = history.map(x => x[1]); const lines = [row.nhi_52w, row.nlo_52w].filter(v => v != null); const min = Math.min(...vals, ...lines), max = Math.max(...vals, ...lines), span = (max - min) || 1; const y = v => h - 4 - ((v - min) / span) * (h - 8); const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${y(v)}`).join(" "); return ( {row.nhi_52w != null && } {row.nlo_52w != null && } ); } // Card compatta per la colonna della Dashboard: livelli della scan serale del titolo. window.ScanLevelsCard = function ScanLevelsCard({ symbol }) { const [d, setD] = useState(null); useEffect(() => { let on = true; setD(null); fetch(`/api/scanner/${encodeURIComponent(symbol)}?news=0`).then(r => r.json()) .then(x => { if (on && !x.error) setD(x); }).catch(() => {}); return () => { on = false; }; }, [symbol]); const r = d && d.row; if (!r) return null; return (
Livelli · scan del {r.date ? r.date.split("-").reverse().join("/") : "—"}
{RANGES.map(([k]) => ( ))}
{RANGE_LABEL[k]} {fmtPx(r[`lo_${k}`])} {fmtPx(r[`hi_${k}`])}
{MAS.map(m => ( M{m} {fmtPct(r[`dma${m}`])} ))}
Mov. medio {r.atr_pct != null ? `${r.atr_pct.toFixed(1)}%` : "—"} {r.atr_pct != null && <> (×3 {(r.atr_pct * 3).toFixed(1)}%)} {r.earn_days != null && 📅 earnings tra {r.earn_days} gg}
); }; function Detail({ symbol, onClose, onStar, starred }) { const [d, setD] = useState(null); const [err, setErr] = useState(null); useEffect(() => { setD(null); setErr(null); fetch(`/api/scanner/${encodeURIComponent(symbol)}`).then(r => r.json()) .then(x => x.error ? setErr(x.error) : setD(x)).catch(e => setErr(String(e))); }, [symbol]); return (
e.stopPropagation()}> {err &&
Errore: {err}
} {!d && !err &&
Caricamento…
} {d && (() => { const r = d.row; return ( <>
{r.symbol}
{r.name || ""}{r.sector ? ` · ${r.sector}` : ""}
{fmtPx(r.close)}
Range (livelli delle sedute precedenti)
{RANGES.map(([k]) => ( ))}
{RANGE_LABEL[k]} {fmtPx(r[`lo_${k}`])} {fmtPx(r[`hi_${k}`])} {r[`pos_${k}`] == null ? "—" : `${r[`pos_${k}`].toFixed(0)}%`}
Medie mobili
{MAS.map(m => ( ))}
Media {m} {fmtPx(r[`ma${m}`])} {r[`dma${m}`] == null ? "—" : (r[`dma${m}`] >= 0 ? "sopra " : "sotto ") + fmtPct(r[`dma${m}`])} {r[`xma${m}`] ? (r[`xma${m}`] > 0 ? "↑ incrocio oggi" : "↓ incrocio oggi") : ""}
Movimento medio giornaliero: {r.atr_pct != null ? `${r.atr_pct.toFixed(1)}%` : "—"} {r.atr_pct != null && <> (×3 {(r.atr_pct * 3).toFixed(1)}%)} · Volume: ×{r.vol_ratio != null ? r.vol_ratio.toFixed(1) : "—"} la media {r.earn_days != null && <> · 📅 earnings tra {r.earn_days} gg}
Motivazione presunta
{(d.reasons || []).map((x, i) => {x})}
{(d.news || []).length === 0 &&
Nessuna notizia aziendale nelle ultime 48 ore.
} {(d.news || []).map((n, i) => (
{n.url ? {n.title} : n.title}
{n.source}{n.published_at ? ` · ${n.published_at.slice(0, 16).replace("T", " ")}` : ""} {(n.tags || []).map(t => {t})}
))}
Le notizie dicono cosa è successo, non se è un problema vero: il giudizio resta vostro.
); })()}
); } window.ScannerView = function ScannerView() { const [raw, setRaw] = useState(null); const [err, setErr] = useState(null); const [q, setQ] = useState(""); const [preset, setPreset] = useState("tutti"); const [brkK, setBrkK] = useState("4m"); const [sector, setSector] = useState(""); const [showEtf, setShowEtf] = useState(false); const [sort, setSort] = useState({ key: "chg1d", dir: -1 }); const [limit, setLimit] = useState(PAGE); const [sel, setSel] = useState(null); const [wl, setWl] = useState([]); const [msg, setMsg] = useState(null); const load = () => fetch("/api/scanner").then(r => r.json()).then(d => { if (d.error) { setErr(d.error); return; } setRaw(d); setWl(d.watchlist || []); setErr(null); }).catch(e => setErr(String(e))); useEffect(() => { load(); const t = setInterval(load, 10 * 60 * 1000); return () => clearInterval(t); }, []); const rows = useMemo(() => { if (!raw) return []; return raw.data.map(a => Object.fromEntries(raw.cols.map((c, i) => [c, a[i]]))); }, [raw]); const sectors = useMemo(() => [...new Set(rows.map(r => r.sector).filter(Boolean))].sort(), [rows]); const wlSet = useMemo(() => new Set(wl), [wl]); const ctx = useMemo(() => Object.fromEntries(rows.filter(r => r.is_etf).map(r => [r.symbol, r.chg1d])), [rows]); const filtered = useMemo(() => { const qq = q.trim().toUpperCase(); let out = rows.filter(r => showEtf || !r.is_etf); if (qq) out = out.filter(r => r.symbol.includes(qq) || (r.name || "").toUpperCase().includes(qq)); if (sector) out = out.filter(r => r.sector === sector); const f = { max52: r => r.bhi_52w || near(r, "hi"), min52: r => r.blo_52w || near(r, "lo"), rotture: r => r[`bhi_${brkK}`] || r[`blo_${brkK}`], sopra200: r => r.dma200 != null && r.dma200 >= 0, sotto200: r => r.dma200 != null && r.dma200 < 0, incroci: r => MAS.some(m => r[`xma${m}`]), stellati: r => wlSet.has(r.symbol), }[preset]; if (f) out = out.filter(f); const { key, dir } = sort; return [...out].sort((a, b) => { const va = a[key], vb = b[key]; if (va == null && vb == null) return 0; if (va == null) return 1; if (vb == null) return -1; return (typeof va === "string" ? va.localeCompare(vb) : va - vb) * dir; }); }, [rows, q, sector, preset, brkK, showEtf, sort, wlSet]); useEffect(() => setLimit(PAGE), [q, sector, preset, brkK, showEtf, sort]); const star = (symbol, want) => { fetch("/api/watchlist/star", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol, starred: want }) }) .then(r => r.json().then(d => ({ ok: r.ok, d }))) .then(({ ok, d }) => { if (!ok || !d || !d.ok || !Array.isArray(d.watchlist)) { setMsg((d && d.error) || "Operazione non riuscita (serve un account admin)."); load(); return; } setWl(d.watchlist); setMsg(want ? `${symbol} ora è seguito dal vivo.` : `${symbol} non è più seguito.`); }).catch(e => { setMsg(String(e)); load(); }); setTimeout(() => setMsg(null), 5000); }; const th = (key, label, title) => ( setSort(s => ({ key, dir: s.key === key ? -s.dir : (key === "symbol" ? 1 : -1) }))}> {label}{sort.key === key ? (sort.dir > 0 ? " ▲" : " ▼") : ""} ); if (err) return
Errore: {err}
; if (!raw) return
Caricamento scanner…
; if (!raw.date) return
Nessuna analisi ancora: lo scanner gira ogni sera alle 23:00 (dopo la chiusura USA).
; return (
setQ(e.target.value)} />
{PRESETS.map(([k, l]) => ( ))}
{preset === "rotture" && ( )} {filtered.length} titoli
{msg &&
{msg}
}
{th("symbol", "Titolo")} {th("close", "Prezzo")} {th("chg1d", "Oggi", "Variazione del giorno (×3 = a leva 3)")} {th("dhi_52w", "Dal max 52s", "Distanza % dal massimo a 52 settimane")} {th("dlo_52w", "Dal min 52s", "Distanza % dal minimo a 52 settimane")} {th("dma200", "Medie", "Pallini: medie 20 · 50 · 100 · 200 (verde sopra, rosso sotto, cerchio = incrocio oggi). Ordina per distanza dalla media 200")} {th("vol_ratio", "Vol", "Volume rispetto alla media delle 20 sedute")} {th("earn_days", "Earn.", "Giorni ai prossimi risultati")} {filtered.slice(0, limit).map(r => ( setSel(r.symbol)} className="sc-row"> ))}
{RANGES.map(([k, l]) => {l})}
{wlSet.has(r.symbol) ? "★" : ""} {r.symbol}
{r.name || ""}
{fmtPx(r.close)} {RANGES.map(([k]) => )} {r.bhi_52w ? "nuovo max" : fmtPct(r.dhi_52w)}{near(r, "hi") && ●} {r.blo_52w ? "nuovo min" : fmtPct(r.dlo_52w)}{near(r, "lo") && ●} = 2 ? "sc-hot" : ""}`}>{r.vol_ratio != null ? `×${r.vol_ratio.toFixed(1)}` : "—"} {r.earn_days != null ? {r.earn_days} gg : ""}
{filtered.length > limit && (
)} {sel && setSel(null)} starred={wlSet.has(sel)} onStar={star} />}
); }; })();