/* 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 && (
)}
);
}
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}` : ""}
onStar(r.symbol, !starred)}
title="Con la stellina il titolo è seguito dal vivo (candele della seduta + avvisi Telegram). Massimo 50.">
{starred ? "★ Seguito" : "☆ Segui dal vivo"}
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 ;
if (!raw) return ;
if (!raw.date) return Nessuna analisi ancora: lo scanner gira ogni sera alle 23:00 (dopo la chiusura USA).
;
return (
{msg &&
{msg}
}
{th("symbol", "Titolo")}
{th("close", "Prezzo")}
{th("chg1d", "Oggi", "Variazione del giorno (×3 = a leva 3)")}
{RANGES.map(([k, l]) => {l} )}
{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">
{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 && (
setLimit(l => l + PAGE)}>Mostra altri {Math.min(PAGE, filtered.length - limit)}
)}
{sel &&
setSel(null)} starred={wlSet.has(sel)} onStar={star} />}
);
};
})();