From 2e565fae4d63fb83fc7404af9e57c74168b8459a Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 07:47:32 +0000 Subject: [PATCH] Add Top Movers screener (mid-cap+, options-tradable) New /movers page surfaces Yahoo Finance's predefined screeners (day_gainers, day_losers, most_actives, most_shorted_stocks) filtered to common equities with market cap >= $2B, so every listed name has a deep options chain. Per-row actions jump straight into Chain / Vol Surface / IV Spike Scanner, or pin the symbol to the Tracker watchlist. - datafetch.ts: fetchMovers(category, count) using yf.screener, post-filtered to quoteType=EQUITY and marketCap >= $2B - options.ts: GET /api/movers?category=&count= - movers.html: Tabler page with 4-tab segmented control, sortable table, summary cards, volume-vs-avg ratio highlighting hot names - All page sidebars: insert "Movers" link between Vol Surface and Scanner Co-Authored-By: Claude Sonnet 4.6 --- backend/src/lib/datafetch.ts | 61 ++++++ backend/src/routes/options.ts | 26 ++- frontend/chain.html | 12 ++ frontend/index.html | 12 ++ frontend/movers.html | 359 ++++++++++++++++++++++++++++++++++ frontend/positions.html | 12 ++ frontend/scanner.html | 2 +- frontend/settings.html | 12 ++ frontend/strategy.html | 12 ++ frontend/surface.html | 12 ++ frontend/tracker.html | 12 ++ 11 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 frontend/movers.html diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts index bce8aed..622226e 100644 --- a/backend/src/lib/datafetch.ts +++ b/backend/src/lib/datafetch.ts @@ -402,3 +402,64 @@ export async function scanSymbol(symbol: string): Promise { ivJumpPct, ivHv, spike, bigMove, expiry, }; } + +// --------------------------------------------------------------------------- +// Top-movers screener — pulls Yahoo's predefined screeners (day_gainers, +// day_losers, most_actives, most_shorted_stocks) and filters out small caps. +// Stocks with options ~= virtually all US equities with marketCap ≥ $2B, +// so we trust the cap filter and skip per-symbol option-chain verification. +// --------------------------------------------------------------------------- + +export type MoverCategory = "day_gainers" | "day_losers" | "most_actives" | "most_shorted_stocks"; + +export type MoverRow = { + symbol: string; + name: string; + price: number; + change: number; + changePct: number; + volume: number; + avgVolume: number; + marketCap: number; + exchange: string; + quoteType: string; +}; + +const MIN_MARKET_CAP = 2_000_000_000; // $2B — mid-cap floor (exclude small/micro caps) + +/** + * Returns up to `count` movers in the given Yahoo screener category, + * filtered to common stocks (EQUITY) with marketCap ≥ $2B. + */ +export async function fetchMovers( + category: MoverCategory, + count = 50 +): Promise { + // Yahoo's screener returns up to ~250 rows; we ask for 100 and post-filter. + const res = await yf.screener({ scrIds: category, count: 100 }, undefined, { validateResult: false }) as any; + const quotes: any[] = res?.quotes ?? []; + + const rows: MoverRow[] = []; + for (const q of quotes) { + if (q?.quoteType !== "EQUITY") continue; + const mc = Number(q.marketCap ?? 0); + if (!Number.isFinite(mc) || mc < MIN_MARKET_CAP) continue; + + rows.push({ + symbol: String(q.symbol ?? "").toUpperCase(), + name: String(q.shortName ?? q.longName ?? q.displayName ?? ""), + price: Number(q.regularMarketPrice ?? 0), + change: Number(q.regularMarketChange ?? 0), + changePct: Number(q.regularMarketChangePercent ?? 0), + volume: Number(q.regularMarketVolume ?? 0), + avgVolume: Number(q.averageDailyVolume3Month ?? 0), + marketCap: mc, + exchange: String(q.fullExchangeName ?? q.exchange ?? ""), + quoteType: String(q.quoteType ?? ""), + }); + + if (rows.length >= count) break; + } + + return rows; +} diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index 2f83d3b..d7280a4 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { fetchOptionsChain, fetchExpirations, scanSymbol } from "../lib/datafetch.js"; +import { fetchOptionsChain, fetchExpirations, scanSymbol, fetchMovers, type MoverCategory } from "../lib/datafetch.js"; /** How old a snapshot can be and still count for the term structure (more lenient than primary TTL). */ const TERM_STRUCTURE_TTL_MS = 30 * 60 * 1000; // 30 minutes @@ -550,6 +550,30 @@ optionsRouter.get("/scan", async (c) => { return c.json(ok({ count: results.length, results })); }); +// --------------------------------------------------------------------------- +// Top-movers screener — Yahoo's day_gainers / day_losers / most_actives / +// most_shorted_stocks, filtered to mid-cap and above (marketCap ≥ $2B). +// --------------------------------------------------------------------------- +const VALID_MOVER_CATEGORIES: MoverCategory[] = [ + "day_gainers", "day_losers", "most_actives", "most_shorted_stocks", +]; + +optionsRouter.get("/movers", async (c) => { + const cat = (c.req.query("category") ?? "day_gainers") as MoverCategory; + if (!VALID_MOVER_CATEGORIES.includes(cat)) { + return c.json(fail(`Invalid category. Use one of: ${VALID_MOVER_CATEGORIES.join(", ")}`), 400); + } + const count = Math.min(Math.max(1, Number(c.req.query("count") ?? 50)), 100); + + try { + const rows = await fetchMovers(cat, count); + return c.json(ok({ category: cat, count: rows.length, results: rows })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json(fail(msg), 500); + } +}); + // PATCH /api/orders/:id { status:'open'|'closed', note?:string } optionsRouter.patch("/orders/:id", async (c) => { const id = Number(c.req.param("id")); diff --git a/frontend/chain.html b/frontend/chain.html index 13837e2..a4191b8 100644 --- a/frontend/chain.html +++ b/frontend/chain.html @@ -105,6 +105,18 @@ + + + - + diff --git a/frontend/settings.html b/frontend/settings.html index 3d2b576..44d553e 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -75,6 +75,18 @@ + + + +