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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user