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 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:47:32 +00:00
parent 3ea5fd5209
commit 2e565fae4d
11 changed files with 530 additions and 2 deletions

View File

@@ -402,3 +402,64 @@ export async function scanSymbol(symbol: string): Promise<ScanResult> {
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<MoverRow[]> {
// 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;
}

View File

@@ -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"));