/* =============================================================================
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. */}
);
}
/* ----- 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 (
);
}
/* ----- 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 (
);
}
/* ----- 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 (
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
);
}
/* ----- 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 (
{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 (
);
}
/* ----- 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 (
<>
>
);
}
const meta = window.STRATEGY_META[idea.strategy];
const isCredit = idea.credit > 0;
return (
<>
>
);
}
/* ----- Scoring breakdown — confluence component bars ---------------------- */
// Renders the 5 confluence components (iv_alignment, technical_match,
// liquidity, spread_tightness, dte_sweet_spot) sorted by their weighted
// contribution to the final score. The bar fill represents the raw
// component score (0..1) so the eye can quickly spot which components
// are pulling the score up vs. holding it back. The right-side numbers
// show the weight (×%) and the absolute score for power users.
const COMPONENT_LABELS = {
iv_alignment: "IV alignment",
technical_match: "Technical match",
liquidity: "Liquidity",
spread_tightness: "Spread tightness",
dte_sweet_spot: "DTE sweet spot",
};
function ScoringBreakdown({ breakdown, confluence }) {
if (!breakdown || !breakdown.length) return null;
return (
);
}
/* ============================================================================
PositionDrawer — slide-in management view for an open position
----------------------------------------------------------------------------
Different from the ideas Drawer because the questions are different:
ideas drawer asks "is this a good trade?", positions drawer asks
"should I close, hold, or roll?" We surface the data that supports
that decision: current P/L, % to max, distance to breakeven, Greeks
(especially theta — daily decay rate), payoff curve with both entry
and current spot markers, and P/L trend since entry.
============================================================================*/
function PositionDrawer({ position, onClose, onCloseClick }) {
// Escape key dismisses the drawer (matches ideas Drawer behavior).
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
if (position) window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [position, onClose]);
if (!position) {
return (
<>
>
);
}
const p = position;
const meta = window.STRATEGY_META[p.strategy];
const isCredit = p.isCredit;
const costDollars = Math.abs(p.entryPrice) * 100 * p.quantity;
// % to max: for credits, how close are we to keeping the full credit
// (max profit). For debits, how close are we to losing the full debit
// (max loss). The dominant management heuristic per tastytrade
// methodology: close credit trades at 50% of max profit; manage debit
// trades at 50% loss as a defensive stop.
const maxProfit = p.maxProfit;
const maxLoss = p.maxLoss;
const pctToMaxProfit = (maxProfit !== null && maxProfit > 0 && p.pnlPerShare !== null)
? Math.max(0, Math.min(1, p.pnlPerShare / maxProfit))
: null;
const pctToMaxLoss = (maxLoss !== null && maxLoss < 0 && p.pnlPerShare !== null && p.pnlPerShare < 0)
? Math.max(0, Math.min(1, p.pnlPerShare / maxLoss))
: null;
// Breakeven distance from current spot, signed positive when in our
// favor (above BE for LC/CDS, below BE for CSP/PCS).
const beDistancePct = (p.breakeven !== null && p.underlying)
? (isCredit
? (p.underlying - p.breakeven) / p.underlying
: (p.underlying - p.breakeven) / p.underlying)
: null;
const dteWarn = p.dte !== null && p.dte <= 21; // tastytrade: manage at 21 DTE
const dteCritical = p.dte !== null && p.dte <= 7;
// Idea-shape adapter so we can reuse PayoffDiagram with position data.
const ideaShape = {
underlying: p.underlying,
breakeven: p.breakeven,
longK: p.longK,
shortK: p.shortK,
credit: p.entryPrice, // already signed correctly
strategy: p.strategy,
};
return (
<>
>
);
}
/* ----- Position health — three small gauges ------------------------------- */
function PositionHealth({ p, pctToMaxProfit, pctToMaxLoss, beDistancePct }) {
const isCredit = p.isCredit;
// Primary gauge depends on strategy:
// - Credit: how close to max profit (target close at 50%, per
// tastytrade's "manage winners" rule)
// - Debit: how close to max loss (defensive stop)
const primary = isCredit
? { label: "% to max profit", value: pctToMaxProfit, target: 0.5,
targetLabel: "close zone (50%+)", color: "var(--bull)" }
: { label: "% to max loss", value: pctToMaxLoss, target: 0.5,
targetLabel: "stop zone (50%+)", color: "var(--bear)" };
const pct = primary.value !== null ? primary.value * 100 : null;
const inTarget = pct !== null && pct >= primary.target * 100;
return (
{primary.label}
{pct === null ? "—" : `${pct.toFixed(0)}%`}
{inTarget && (
{isCredit ? "→ Consider closing — at or past 50% max profit" :
"→ Consider stop — at or past 50% max loss"}
)}
Days to expiration
{/* Bar fills from 60 days down to 0 — DTE 60 = full bar, DTE 0 = empty. */}
{/* 21 DTE marker — tastytrade's manage threshold */}
{p.dte}d
{p.dte !== null && p.dte <= 21 && (
→ {p.dte <= 7 ? "≤7 DTE — gamma risk is high; close or roll" :
"≤21 DTE — tastytrade roll/close threshold"}
);
}
/* ----- P/L trend sparkline (since entry) --------------------------------- */
function PositionPnlTrend({ trend, currentPnl }) {
// Need at least 2 points (or 1 historical + current) to draw.
const points = [...trend];
if (currentPnl !== null && currentPnl !== undefined) {
points.push({ capturedAt: null, pnlPerContract: currentPnl });
}
if (points.length < 2) return null;
const W = 480, H = 60, pad = 4;
const values = points.map(p => p.pnlPerContract);
const min = Math.min(...values, 0); // include zero baseline
const max = Math.max(...values, 0);
const range = (max - min) || 1;
const step = (W - 2 * pad) / (points.length - 1);
const pathD = points.map((p, i) => {
const x = pad + i * step;
const y = pad + (1 - (p.pnlPerContract - min) / range) * (H - 2 * pad);
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(" ");
const zeroY = pad + (1 - (0 - min) / range) * (H - 2 * pad);
const isUp = points[points.length - 1].pnlPerContract >= points[0].pnlPerContract;
const stroke = isUp ? "var(--bull)" : "var(--bear)";
return (
P/L trend since entry
{points.length} datapoints
min ${min.toFixed(0)} / max ${max.toFixed(0)}
);
}
/* ============================================================================
Positions tab — manual portfolio tracker
----------------------------------------------------------------------------
The operator places trades on Robinhood (or another broker) and submits
the fill data here. The dashboard marks each open position to market on
every refresh by looking up the contract in the latest option_snapshots.
PositionsView — top-level layout (hero summary + table)
PositionsHero — three summary cards (open count + total P/L + best)
PositionsTable — list of open positions with current vs entry
ClosedHistory — collapsible last-N closed positions with realized P/L
AddPositionModal — strategy-aware form for creating a position
ClosePositionModal — exit price / date prompt
============================================================================*/
function PositionsView({ onAddClick, onCloseClick, onSelectPosition }) {
const positions = window.POSITIONS || [];
const open = positions.filter(p => p.status === "open");
return (
<>
Open positions
{open.length === 0
? "no open positions yet — click + Add position or use the Track icon on any idea"
: `${open.length} open · click any row for full management view · marked to market every scan`}
{open.length > 0 && }
{/* Closed trades live in the History tab now (third top-level tab).
Phase 4k moved them out of PositionsView so this view is a clean
"what am I currently managing" surface. */}
>
);
}
function PositionsHero({ open }) {
const totalPnl = open.reduce((sum, p) => sum + (p.pnlTotal || 0), 0);
const totalCapital = open.reduce((sum, p) => {
// Capital at risk = max loss × qty for spreads/LC; for credit-only
// (CSP) treat the cash-secured collateral as the strike × qty × 100.
if (p.maxLoss !== null && p.maxLoss !== undefined) {
return sum + Math.abs(p.maxLoss) * 100 * p.quantity;
}
return sum;
}, 0);
// Best / worst of the open set, by % return.
const ranked = open
.filter(p => p.pnlPerShare !== null && p.entryPrice)
.map(p => ({ ...p, pct: p.pnlPerShare / Math.abs(p.entryPrice) }))
.sort((a, b) => b.pct - a.pct);
const best = ranked[0];
const worst = ranked[ranked.length - 1];
return (
);
}
/* ----- Add-position modal ---------------------------------------------------
* Strategy-aware form. Renders the right strike inputs based on which
* strategy is selected. Validates client-side and POSTs to /positions;
* on success reloads the page (data.js refreshes with the new entry).
*
* Sign convention for entry_price: stored as SIGNED net cash flow.
* LC, CDS (debit): negative (cash out at fill)
* CSP, PCS (credit): positive (cash in at fill)
* The form takes a positive magnitude from the user and applies the
* sign automatically based on strategy — operators don't think in
* negatives. ``prefill`` from a Track-button click already has the
* signed value (idea.credit).
* --------------------------------------------------------------------------- */
function AddPositionModal({ prefill, onClose }) {
const init = prefill || {};
const [ticker, setTicker] = useState(init.ticker || "");
const [strategy, setStrategy] = useState(init.strategy || "lc");
const [longK, setLongK] = useState(init.longK ?? "");
const [shortK, setShortK] = useState(init.shortK ?? "");
const [expiration, setExpiration] = useState(init.expiration || "");
const [quantity, setQuantity] = useState(init.quantity || 1);
// Entry price as a positive magnitude. Signed value computed at submit.
const [entryMagnitude, setEntryMagnitude] = useState(
init.entry_price !== undefined ? Math.abs(init.entry_price).toFixed(2) : ""
);
const [dateOpened, setDateOpened] = useState(
init.date_opened || new Date().toISOString().slice(0, 10)
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const isCredit = strategy === "pcs" || strategy === "csp";
const showLongK = strategy === "lc" || strategy === "cds" || strategy === "pcs";
const showShortK = strategy === "cds" || strategy === "pcs" || strategy === "csp";
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const submit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const mag = parseFloat(entryMagnitude);
if (isNaN(mag) || mag <= 0) throw new Error("Entry price must be a positive number");
const body = {
ticker: ticker.trim().toUpperCase(),
strategy,
long_strike: showLongK && longK !== "" ? parseFloat(longK) : null,
short_strike: showShortK && shortK !== "" ? parseFloat(shortK) : null,
expiration,
quantity: parseInt(quantity, 10),
// Sign-applied: + for credit strategies, − for debit. Stored
// this way so dashboard's mark-to-market formula is uniform.
entry_price: isCredit ? mag : -mag,
date_opened: dateOpened,
};
const r = await fetch("/positions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
const msg = await r.text();
throw new Error(`POST /positions → ${r.status}: ${msg}`);
}
// Reload to pick up fresh data.js with the new position included.
window.location.reload();
} catch (err) {
setError(err.message);
setSubmitting(false);
}
};
return (
<>
Add position
Manual entry — fill values come from your broker (Robinhood, etc.)
>
);
}
function ClosePositionModal({ positionId, onClose }) {
const position = (window.POSITIONS || []).find(p => p.id === positionId);
const [exitMagnitude, setExitMagnitude] = useState(
position && position.currentMid !== null ? Math.abs(position.currentMid).toFixed(2) : ""
);
const [dateClosed, setDateClosed] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (!position) return null;
const isCredit = position.isCredit;
const submit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const mag = parseFloat(exitMagnitude);
if (isNaN(mag) || mag < 0) throw new Error("Exit price must be ≥ 0");
// exit_price uses the same sign convention as currentMid: signed
// close cash flow with opposite sign to entry direction.
// For LC/CDS (debit entry, signed -): exit cash IN = +mag
// For CSP/PCS (credit entry, signed +): exit cash OUT = -mag
const signedExit = isCredit ? -mag : mag;
const r = await fetch(`/positions/${positionId}/close`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exit_price: signedExit, date_closed: dateClosed }),
});
if (!r.ok) {
const msg = await r.text();
throw new Error(`PATCH /positions/${positionId}/close → ${r.status}: ${msg}`);
}
window.location.reload();
} catch (err) {
setError(err.message);
setSubmitting(false);
}
};
return (
<>
Close {position.ticker} {position.strategy}
Record realized exit. Position moves to closed history.
>
);
}
/* ============================================================================
History tab — closed trades + lifetime performance
----------------------------------------------------------------------------
The third top-level tab. Shows realized P/L only (no marking-to-market) —
each trade's outcome was locked in when it closed. Hero stats give the
operator a track record at a glance: total realized P/L, trades closed,
win rate. Table below lists every closed trade with sort options.
HistoryView — top-level layout
HistoryHero — three summary cards
HistoryTable — closed-trades list, sortable
============================================================================*/
function HistoryView() {
const all = window.POSITIONS || [];
const closed = all.filter(p => p.status === "closed");
return (
<>
Closed trades
{closed.length === 0
? "no closed trades yet — close a position from the Positions tab to see it here"
: `${closed.length} closed · realized P/L locked in at exit`}
);
}
/* ----- Help Modal ---------------------------------------------------------- */
// Centralized definitions panel — replaces the per-column glossary popovers
// and the strategy-pill hover. One ? button in the top bar opens this; the
// goal is "one place to learn the vocabulary" rather than scattered hovers
// the operator has to discover individually.
function HelpModal({ open, onClose }) {
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<>
Per contract (= per-share × 100). PCS/CSP collect a credit (CR — you receive cash), CDS/LC pay a debit (DB — you pay cash). One contract controls 100 shares, so what you see is what hits your account at fill.
Max Profit
Per contract theoretical maximum at expiration. Spreads (CDS, PCS) cap at width − debit (CDS) or = credit (PCS). Long calls are unbounded; CSP max = the credit collected.
POP
Probability of Profit. Approximated from option deltas — for credit strategies it's the chance the short leg finishes OTM; for CDS it's a breakeven-aware interpolation between the two deltas. Rough heuristic; fine for ranking, not for risk-of-ruin math.
IV Rank
Implied Volatility Rank from Tastytrade MarketMetrics — where current IV sits in its trailing 252-day range. 1.0 = at the high (premium expensive); 0.0 = at the low (premium cheap). PCS/CSP want HIGH IVR (rich premium to sell); CDS/LC want LOW IVR (cheap premium to buy).
Confluence
Weighted blend of 5 components, with strategy-specific weights:
Components: IV alignment (does IV regime match the strategy — high IV good for sellers, low IV good for buyers), technical match (Finviz bullish score), liquidity (primary-leg OI / 2000), spread tightness (1 − (ask − bid) / mid), DTE sweet spot (peaks at 35 DTE).
Validated against historical Polygon options chains (Phase 4h backtest): LC top quintile averaged +$377/contract higher P/L than bottom quintile; CDS +$181. Credit-strategy results were regime-confounded (bull market). Treat the score as "expected-value rank for direction-driven trades, premium-quality rank for credit trades" — not as P/L prediction.
Trend
12-day underlying-price sparkline derived from option_snapshots.underlying_price. Renders as — until ≥2 distinct trading days of scan history exist for the ticker.
Reading the row
Strikes (Short / Long)
Spreads show short / long. Single-leg trades (LC, CSP) show only the long-side strike. Long Call: the strike you buy. CDS: long is the lower call you buy, short is the higher call you sell. PCS: long is the lower put you buy for protection, short is the higher put you sell for premium.
Confluence bar
Visual readout of the score 0–100%. Click any row for the per-component breakdown in the drawer.
Strategy parameters come from the tastytrade methodology and conventional retail-options resources (delta bands, DTE sweet spot, IV-rank gates, OI minimums). Confluence weights are author-picked defaults. None of this has been backtested in this codebase.
>
);
}
/* ----- Refresh controller --------------------------------------------------
* Owns the polling lifecycle for the dashboard's two refresh buttons.
*
* Flow:
* 1. User clicks → POST /refresh/{light,universe} → server flips state to running
* 2. Hook polls /refresh/status every 1s while running
* 3. When server reports finished_at, we wait one more poll cycle (so
* the dashboard's static data.js has a chance to be regenerated by
* build_dashboard before we reload), then trigger a full window
* reload to pick up fresh window.IDEAS.
*
* Errors surface in `state.error` and are shown in the banner — the
* server keeps the error state until the next refresh starts.
* --------------------------------------------------------------------------- */
function useRefreshController() {
const [state, setState] = useState({
status: "idle", kind: null, stage: null,
current: 0, total: 0, message: "", error: null,
startedAt: null, finishedAt: null, elapsedSeconds: null,
});
const finishedRef = useRef(false);
const fetchStatus = async () => {
try {
const r = await fetch("/refresh/status", { cache: "no-store" });
const j = await r.json();
setState({
status: j.status, kind: j.kind, stage: j.stage,
current: j.current, total: j.total,
message: j.message, error: j.error,
startedAt: j.started_at, finishedAt: j.finished_at,
elapsedSeconds: j.elapsed_seconds,
});
return j;
} catch (e) {
// Likely the user is running plain http.server (no /refresh API)
// — silently ignore; the buttons will just not trigger.
return null;
}
};
// Poll while running; auto-reload after refresh completes.
useEffect(() => {
if (state.status !== "running") return;
finishedRef.current = false;
const id = setInterval(async () => {
const j = await fetchStatus();
// First pass: when server reports idle and finishedRef hasn't been
// marked, mark it so the NEXT poll cycle triggers the reload —
// gives the dashboard build a moment to write fresh data.js.
if (j && j.status === "idle" && j.finished_at && !finishedRef.current) {
finishedRef.current = true;
if (!j.error) {
// Brief "complete" state shown to the operator, then reload.
setTimeout(() => window.location.reload(), 800);
}
}
}, 1000);
return () => clearInterval(id);
}, [state.status]);
const trigger = async (kind) => {
if (state.status === "running") return;
setState(s => ({ ...s, status: "running", kind, stage: "starting",
message: "Starting...", current: 0, total: 0, error: null }));
try {
const r = await fetch(`/refresh/${kind}`, { method: "POST" });
if (!r.ok) {
const text = await r.text();
throw new Error(`POST /refresh/${kind} → ${r.status}: ${text}`);
}
// Poll once immediately so the banner picks up the real first-stage state.
await fetchStatus();
} catch (e) {
setState(s => ({ ...s, status: "idle", error: e.message,
message: "Refresh failed to start" }));
}
};
// On mount, sync once in case a refresh is already running (e.g. the
// user opened a second tab while one is mid-flight).
useEffect(() => { fetchStatus(); }, []);
return { state, trigger };
}
/* ----- Tab-visible auto-refresh ------------------------------------------
* Triggers a light (Tier 1) refresh every AUTO_REFRESH_MINUTES when the
* dashboard tab is in the foreground. Pauses entirely when the tab is
* hidden so we're not running scans against an audience of zero. Also
* runs once immediately when the tab becomes visible after being hidden
* for longer than the cadence — so "I came back to the tab" feels like
* "data is current," not "let me wait 8 minutes."
*
* Off by default; the operator opts in via a toggle. Storing the
* preference in localStorage so it persists across reloads.
* --------------------------------------------------------------------------- */
const AUTO_REFRESH_MINUTES = 8;
const AUTO_REFRESH_KEY = "os-auto-refresh";
function useAutoRefresh(refresh) {
const [enabled, setEnabledState] = useState(() => {
return localStorage.getItem(AUTO_REFRESH_KEY) === "1";
});
const lastRunRef = useRef(0);
const setEnabled = (val) => {
setEnabledState(val);
localStorage.setItem(AUTO_REFRESH_KEY, val ? "1" : "0");
};
useEffect(() => {
if (!enabled) return;
const tickIfDue = () => {
// Don't double-fire if a refresh is already in flight.
if (refresh.state.status === "running") return;
// Don't refresh if the tab is hidden.
if (document.hidden) return;
const now = Date.now();
const since = now - lastRunRef.current;
if (since < AUTO_REFRESH_MINUTES * 60 * 1000) return;
lastRunRef.current = now;
refresh.trigger("light");
};
// Tick once immediately if it's been long enough since the last run
// — covers "came back to the tab after lunch" without a 8-min wait.
tickIfDue();
const interval = setInterval(tickIfDue, 30 * 1000); // check every 30s
const onVis = () => { if (!document.hidden) tickIfDue(); };
document.addEventListener("visibilitychange", onVis);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVis);
};
}, [enabled, refresh]);
return { enabled, setEnabled };
}
/* ----- Refresh banner — sticky progress strip during a running refresh ---- */
function RefreshBanner({ state }) {
if (state.status !== "running" && !state.error) return null;
const pct = state.total > 0 ? Math.min(100, Math.round((state.current / state.total) * 100)) : 0;
const indeterminate = state.total === 0;
const label = state.kind === "universe" ? "Universe refresh" :
state.kind === "light" ? "Quick refresh" : "Refreshing";
return (
{/* Top-level tab toggle: Ideas (the scanner) vs Positions
(manual portfolio tracker). State lives in App via the
``tab`` prop; ``onTabChange`` updates it + persists to
localStorage so the user lands back where they left. */}
{/* Tier 1 refresh: re-extract chains for current top-30. ~60-90s. */}
{/* Tier 2 refresh: Finviz universe re-rank + scan. ~75-135s. */}
{/* Auto-refresh toggle — re-runs the quick refresh every 8 min
while the dashboard tab is visible. Indigo dot when on. */}