/* ============================================================================= Components — Options Scanner ===========================================================================*/ const { useState, useEffect, useMemo, useRef } = React; /* ----- Icons --------------------------------------------------------------- */ const Icon = { Logo: () => , Sun: () => , Moon: () => , Search: () => , Refresh: () => , Close: () => , List: () => , Group: () => , Arrow: () => , }; /* ----- Helpers ------------------------------------------------------------- */ const fmtMoney = (n) => { if (n === null || n === undefined || Number.isNaN(n)) return "—"; const sign = n < 0 ? "-" : ""; const abs = Math.abs(n); return `${sign}$${abs.toFixed(2)}`; }; const fmtPct = (n) => (n === null || n === undefined ? "—" : `${(n * 100).toFixed(1)}%`); // Null-safe formatters. The design's mock data always had a value at every // field; our DuckDB rows can legitimately be null (Greeks before a snapshot // is bound to the leg, sparklines before multi-day history accumulates, // etc), so the drawer + grouped view need to tolerate them without crashing. const fmtNum = (n, decimals = 2) => (n === null || n === undefined ? "—" : n.toFixed(decimals)); const fmtPrice = (n) => (n === null || n === undefined ? "—" : `$${n.toFixed(2)}`); // Per-CONTRACT dollar formatter. The data layer stores per-share P/L // (credit, max profit, max loss) because that's what the underlying // options math computes. Brokerages quote per-contract (= per-share × 100), // so display columns in the table and drawer use this formatter for // dollar amounts that flow as cash. Strikes/breakeven/underlying stay // per-share since those are stock prices, not P/L flows. const fmtContractMoney = (n) => { if (n === null || n === undefined || Number.isNaN(n)) return "—"; const sign = n < 0 ? "-" : ""; const abs = Math.abs(n) * 100; return `${sign}$${abs.toFixed(0)}`; }; /* ----- Sparkline ----------------------------------------------------------- */ function Sparkline({ data, width = 80, height = 28, stroke, fill }) { // Need at least 2 points; otherwise step = width / 0 = Infinity and the // SVG renders nothing visible. Common when scan history is just starting // to accumulate (one snapshot per day per ticker). if (!data || data.length < 2) return null; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const step = width / (data.length - 1); const points = data.map((v, i) => [i * step, height - ((v - min) / range) * (height - 4) - 2]); const pathD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0].toFixed(2)} ${p[1].toFixed(2)}`).join(" "); const areaD = `${pathD} L ${width} ${height} L 0 ${height} Z`; const isUp = data[data.length - 1] >= data[0]; const c = stroke || (isUp ? "var(--bull)" : "var(--bear)"); const f = fill || (isUp ? "color-mix(in oklab, var(--bull) 14%, transparent)" : "color-mix(in oklab, var(--bear) 14%, transparent)"); return ( ); } /* ----- Strategy pill ------------------------------------------------------- */ // The glossary popover that used to live here was retired in favor of the // centralized ? help modal in the top bar. Keeping the styled pill but // dropping the cursor:help + popover keeps the UI quiet without losing // the strategy → color affordance. function StratPill({ code }) { return ( {code} ); } /* ----- Confluence Spectrum ------------------------------------------------- */ function ConfluenceSpectrum({ ideas, onPick, hoveredId, setHoveredId }) { const min = 0.5, max = 0.9; const top = ideas.reduce((a, b) => (a.confluence > b.confluence ? a : b)); const placed = useMemo(() => ideas.map(i => ({ ...i, pct: Math.max(0, Math.min(1, (i.confluence - min) / (max - min))) * 100, })), [ideas]); return (
Confluence Spectrum
all {ideas.length} ideas plotted by score
{/* The "MP · 89.7%" floating callout was removed in Phase 4j — the pulsing is-top dot already differentiates the leader, and the same info shows on hover via the per-dot tooltip ("MP LC · 89.7%"). The callout was redundant. */}
50%
60%
70%
80%
90%
{placed.map(p => { const stratColor = `var(--${p.strategy.toLowerCase()})`; const isTop = p.id === top.id; const isHover = hoveredId === p.id; return (
onPick(p.id)} onMouseEnter={() => setHoveredId(p.id)} onMouseLeave={() => setHoveredId(null)} title={`${p.ticker} ${p.strategy} · ${(p.confluence * 100).toFixed(1)}%`} /> ); })}
); } /* ----- Hero ---------------------------------------------------------------- */ // Format a UTC ISO timestamp as the viewer's local time. Locale-aware so // the year/month/day order matches whatever the browser is set to. Falls // back to the legacy server-rendered string if the ISO field is missing. function formatLocalScanTime(iso, fallback) { if (!iso) return fallback || ""; const d = new Date(iso); if (isNaN(d.getTime())) return fallback || ""; const date = d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); return `${date} · ${time}`; } function Hero({ ideas, onPick }) { const [hoveredId, setHoveredId] = useState(null); const counts = window.SCAN.strategyCounts; const diff = window.SCAN.diff; // Derive a single representative DTE/expiration to show in the subtitle. // Today every scan emits one expiration but that won't always be true, // so pick the most-frequent one rather than hardcoding "41 DTE / jun-18". const expCounts = ideas.reduce((m, i) => { if (i.exp) m.set(i.exp, (m.get(i.exp) || 0) + 1); return m; }, new Map()); const topExp = expCounts.size ? [...expCounts.entries()].sort((a, b) => b[1] - a[1])[0] : null; const topIdea = topExp ? ideas.find(i => i.exp === topExp[0]) : null; const expLabel = topIdea ? `${topIdea.dte} DTE · ${new Date(topIdea.exp).toLocaleDateString(undefined, { month: "short", day: "numeric" }).toLowerCase()} chain` : ""; return (
Latest scan
{window.SCAN.totalIdeas}
ideas across {window.SCAN.totalTickers} tickers
{formatLocalScanTime(window.SCAN.scannedAtIso, window.SCAN.scannedAt)} {expLabel && <>  ·  {expLabel}}
{Object.entries(counts).map(([code, n]) => ( {code} {n} ))}
Δ vs last scan
Sustained
{diff.sustained}
); } /* ----- Diff row with hover detail popover -------------------------------- * The Δ-vs-last-scan card was previously a static count. Now each row * shows a hover popover listing the actual tickers + their confluence * scores so the operator can see WHO rotated in/out, not just HOW MANY. * * For "added": clicking a ticker in the popover scrolls to / opens that * idea's drawer (via the `onPick` callback wired to setSelectedId). * For "dropped": tickers are display-only — they're not in the current * scan, so we can't open their drawer. * --------------------------------------------------------------------------- */ function DiffRow({ label, count, countClass, prefix, details, emptyHint, accent, onPick }) { const items = details || []; const hasItems = items.length > 0; const findIdeaId = (d) => { // Match the synthesized id format from dashboard.py:_idea_id if (!window.IDEAS) return null; const match = window.IDEAS.find(i => i.ticker === d.ticker && i.strategy === d.strategy && i.exp === d.exp ); return match ? match.id : null; }; return (
{label}
{prefix}{count}
{hasItems && (
{label.toLowerCase()}
    {items.map((d, idx) => { const ideaId = onPick ? findIdeaId(d) : null; const interactive = !!ideaId; return (
  • onPick(ideaId) : undefined} >
    {d.ticker} {d.strategy} {d.confluence !== null && d.confluence !== undefined ? `${(d.confluence * 100).toFixed(1)}%` : "—"}
    {/* Reason tag (dropped-only). For "added" details we don't show a reason since "newly qualified" is the implicit reason. */} {d.reasonLabel && (
    {d.reasonLabel}
    )}
  • ); })}
)} {!hasItems && (
{emptyHint}
)}
); } /* ----- Filter Toolbar ------------------------------------------------------ */ function Toolbar({ activeStrat, setActiveStrat, view, setView, query, setQuery }) { const counts = window.SCAN.strategyCounts; const total = window.SCAN.totalIdeas; return (
{Object.entries(counts).map(([code, n]) => ( ))}
setQuery(e.target.value)} placeholder="Ticker..." />
); } /* ----- Confluence trend (24h) --------------------------------------------- * `idea.confluenceHistory` is a list of {capturedAt, score} from prior * scans within 24h. We compare the oldest in-window score against the * current confluence to decide direction (↑ rising, ↓ falling, → flat), * and show a tiny arrow + delta in the table; the drawer renders the * full series as a sparkline. * * Hidden entirely when fewer than 2 prior datapoints exist (no real * trend) so the dashboard isn't cluttered with "—" placeholders early * in the day. * --------------------------------------------------------------------------- */ const TREND_FLAT_THRESHOLD = 0.005; // ±0.5pp in confluence treated as flat function _trendDirection(history, current) { if (!history || history.length < 1 || current === null || current === undefined) return null; const earliest = history[0].score; const delta = current - earliest; if (Math.abs(delta) < TREND_FLAT_THRESHOLD) return { dir: "flat", delta }; return { dir: delta > 0 ? "up" : "down", delta }; } function ConfluenceTrendArrow({ idea }) { const trend = _trendDirection(idea.confluenceHistory, idea.confluence); if (!trend) return null; const arrow = trend.dir === "up" ? "↑" : trend.dir === "down" ? "↓" : "→"; const sign = trend.delta >= 0 ? "+" : "−"; const pct = (Math.abs(trend.delta) * 100).toFixed(1); return ( {arrow} ); } function ConfluenceTrendSparkline({ idea }) { const history = idea.confluenceHistory || []; // Need at least 2 points to draw a meaningful trajectory; the // current confluence anchors the right edge of the line. if (history.length < 1 || idea.confluence === null) return null; const series = [...history.map(h => h.score), idea.confluence]; if (series.length < 2) return null; const W = 200, H = 36, pad = 2; const min = Math.min(...series), max = Math.max(...series); const range = max - min || 0.001; const step = (W - 2 * pad) / (series.length - 1); const pathD = series.map((v, i) => { const x = pad + i * step; const y = pad + (1 - (v - min) / range) * (H - 2 * pad); return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`; }).join(" "); const lastY = pad + (1 - (idea.confluence - min) / range) * (H - 2 * pad); const lastX = pad + (series.length - 1) * step; const isUp = idea.confluence >= series[0]; return (
24h confluence trend {(min * 100).toFixed(1)}% – {(max * 100).toFixed(1)}%
); } /* ----- Idea Row (in list view) -------------------------------------------- */ function IdeaRow({ idea, selected, onClick, onTrack }) { const isCredit = idea.credit > 0; const confPct = (idea.confluence * 100).toFixed(1); return (
{/* Stop propagation so clicking the ticker opens TradingView in a new tab without ALSO triggering the row's drawer onClick. */} e.stopPropagation()} title={`Open ${idea.ticker} on TradingView Supercharts`} > {idea.ticker} {idea.isNew && NEW}
{fmtPrice(idea.underlying)} {idea.exp} {idea.dte}d {idea.shortK !== null ? `$${idea.shortK.toFixed(2)}` : "—"} / {idea.longK !== null ? `$${idea.longK.toFixed(2)}` : "—"} {fmtContractMoney(Math.abs(idea.credit))} {isCredit ? "CR" : "DB"} {fmtContractMoney(idea.maxProfit)} {fmtPct(idea.pop)} {fmtPct(idea.ivRank)}
{confPct}%
{idea.spark && idea.spark.length >= 2 ? : } {onTrack && ( )} ); } /* ----- Sortable column header --------------------------------------------- */ // Renders a clickable with an arrow indicator when active. Non-sortable // columns (Strikes, Trend) just render the static label and don't fire onSort. function SortableTH({ column, label, sort, onSort, sortable = true }) { if (!sortable || !onSort) return {label}; const isActive = sort && sort.column === column; const cls = `sortable ${isActive ? "is-sorted" : ""}`; return ( onSort(column)}> {label} ); } /* ----- List view ----------------------------------------------------------- */ function IdeasTable({ ideas, selectedId, onSelect, sort, onSort, onTrackPosition }) { return (
{ideas.map(idea => ( onSelect(idea.id)} onTrack={onTrackPosition} /> ))}
); } /* ----- Grouped view -------------------------------------------------------- */ function GroupedView({ ideas, selectedId, onSelect }) { const groups = useMemo(() => { const m = new Map(); ideas.forEach(i => { if (!m.has(i.ticker)) m.set(i.ticker, []); m.get(i.ticker).push(i); }); const arr = [...m.entries()].map(([ticker, list]) => ({ ticker, list, best: Math.max(...list.map(x => x.confluence)), underlying: list[0].underlying, spark: list[0].spark, })); arr.sort((a, b) => b.best - a.best); return arr; }, [ideas]); return (
{groups.map(g => (
e.stopPropagation()} title={`Open ${g.ticker} on TradingView Supercharts`} > {g.ticker}
{fmtPrice(g.underlying)}
{g.list.length} idea{g.list.length > 1 ? "s" : ""}
top score
{(g.best * 100).toFixed(1)}%
{g.list.map(idea => ( onSelect(idea.id)} /> ))}
))}
); } /* ----- Confluence ring ----------------------------------------------------- */ function ConfRing({ value, size = 96, stroke = 8 }) { const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const dash = c * value; return (
{(value * 100).toFixed(0)}
); } /* ----- Payoff Diagram ------------------------------------------------------ */ function PayoffDiagram({ idea, entrySpot }) { const W = 480, H = 180, padX = 26, padY = 18; const u = idea.underlying; const svgRef = useRef(null); // Hover/drag state — null means "not interacting; show spot only". // Set to a stock price (in dollars) when the user moves the mouse over // the chart, so we can render a movable cursor + per-contract P/L // readout at that price. const [hoverPrice, setHoverPrice] = useState(null); // Bail out gracefully if we don't have the inputs the curve math needs. // Happens when the primary leg's snapshot is missing (no underlying // price), or breakeven wasn't computed at strategy time. if (u === null || u === undefined || idea.breakeven === null || idea.breakeven === undefined) { return (
Payoff curve unavailable — underlying price or breakeven missing.
); } // Compute strike spread, P/L curve. const strikes = [idea.longK, idea.shortK].filter(x => x !== null && x !== undefined); const minStrike = Math.min(...strikes, u) * 0.85; const maxStrike = Math.max(...strikes, u) * 1.15; const xRange = maxStrike - minStrike; // Payoff at price S (long position only — debit is negative credit field) const payoff = (S) => { const code = idea.strategy; const debit = -idea.credit; // positive for debit, negative for credit collected if (code === "LC") { return Math.max(0, S - idea.longK) - debit; } else if (code === "CSP") { // Short put: collect credit, lose if S < strike return idea.credit - Math.max(0, idea.longK - S); } else if (code === "PCS") { // Put credit spread: sell HIGHER-strike put (shortK), buy LOWER-strike // put (longK). dashboard.py emits shortK = sold strike (higher) and // longK = bought strike (lower), matching the design's mock data. // Payoff = credit collected − sold-leg intrinsic + bought-leg intrinsic. const shortP = Math.max(0, idea.shortK - S); const longP = Math.max(0, idea.longK - S); return idea.credit - shortP + longP; } else if (code === "CDS") { // Buy lower-strike call (longK), sell higher-strike call (shortK) const longC = Math.max(0, S - idea.longK); const shortC = Math.max(0, S - idea.shortK); return -(-idea.credit) + longC - shortC; // -(debit paid) + ... } return 0; }; const N = 60; const samples = Array.from({ length: N }, (_, i) => { const S = minStrike + (xRange * i) / (N - 1); return { S, P: payoff(S) }; }); const Pmin = Math.min(...samples.map(s => s.P)); const Pmax = Math.max(...samples.map(s => s.P)); const Prange = (Pmax - Pmin) || 1; const xToPx = (S) => padX + ((S - minStrike) / xRange) * (W - 2 * padX); const yToPx = (P) => padY + (1 - (P - Pmin) / Prange) * (H - 2 * padY); const zeroY = yToPx(0); // Map a mouse-event clientX into a stock-price S, clamped to the chart's // visible price range so the readout never goes off-axis. Uses the SVG's // own coordinate system via getBoundingClientRect so it stays correct // regardless of CSS scaling. const handleMouseMove = (e) => { const svg = svgRef.current; if (!svg) return; const rect = svg.getBoundingClientRect(); const localX = ((e.clientX - rect.left) / rect.width) * W; if (localX < padX || localX > W - padX) { setHoverPrice(null); return; } const S = minStrike + ((localX - padX) / (W - 2 * padX)) * xRange; setHoverPrice(Math.max(minStrike, Math.min(maxStrike, S))); }; const handleMouseLeave = () => setHoverPrice(null); const cursor = hoverPrice !== null ? { S: hoverPrice, P: payoff(hoverPrice) } : null; // Build profit (>0) and loss (<0) regions as separate paths const dCurve = samples.map((s, i) => `${i === 0 ? "M" : "L"} ${xToPx(s.S).toFixed(2)} ${yToPx(s.P).toFixed(2)}` ).join(" "); return (
{/* Zero line */} {/* Filled regions (clip the curve area to above/below zero) */} {/* P/L curve */} {/* Strike markers */} {strikes.map((K, i) => ( ${K} ))} {/* Entry-spot marker (positions only — when entrySpot prop is passed). Drawn first so the current-spot marker layers on top if both happen to be at the same price. Uses --text-3 (grey) so the BRAND-colored current spot stays the visual anchor. */} {entrySpot !== undefined && entrySpot !== null && entrySpot >= minStrike && entrySpot <= maxStrike && ( entry ${entrySpot.toFixed(2)} )} {/* Underlying marker */} spot ${u.toFixed(2)} {/* Breakeven */} {/* Hover cursor — vertical line + dot on the curve when the mouse is over the chart. The readout text floats above so it never overlaps the curve at extreme x positions. */} {cursor && ( )}
{cursor ? ( <> At ${cursor.S.toFixed(2)}: = 0 ? "var(--bull)" : "var(--bear)" }}> {cursor.P >= 0 ? "+" : "−"}${(Math.abs(cursor.P) * 100).toFixed(0)} per contract hover to inspect; leave to reset ) : ( <> Profit zone Loss zone Spot ${idea.underlying.toFixed(2)} ○ Breakeven ${idea.breakeven.toFixed(2)} )}
); } /* ----- Underlying chart ---------------------------------------------------- */ function UnderlyingChart({ idea }) { const W = 480, H = 100, padX = 14, padY = 10; const data = idea.spark; const min = Math.min(...data, ...(idea.shortK ? [idea.shortK] : []), idea.longK); const max = Math.max(...data, ...(idea.shortK ? [idea.shortK] : []), idea.longK); const range = max - min || 1; const step = (W - 2 * padX) / (data.length - 1); const x = i => padX + i * step; const y = v => padY + (1 - (v - min) / range) * (H - 2 * padY); const d = data.map((v, i) => `${i === 0 ? "M" : "L"} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(" "); const isUp = data[data.length-1] >= data[0]; return (
{idea.longK && ( )} {idea.shortK && ( )}
); } /* ----- Drawer -------------------------------------------------------------- */ function Drawer({ idea, onClose }) { // Trap escape key useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; if (idea) window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [idea, onClose]); if (!idea) { return ( <>