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 @@ + + + +