Skip to content

Commit f6d8ee5

Browse files
ralyodioclaude
andcommitted
feat(finance): near-live quotes + market-session badge on watchlist & ticker
Watchlist cards and the ticker page now refresh their numbers without a reload and show the trading session (pre-market / open / after-hours / closed). No websockets — prod runs keyless Yahoo and WS streaming is a PRD non-goal — so this is visibility-aware polling that pauses when the tab is hidden or the market is closed. - Quote gains marketState (PRE/REGULAR/POST/CLOSED). Yahoo getQuote rewritten to use the intraday chart `meta` (live regularMarketPrice + pre/post bars for the true last price) and derive the session from meta.currentTradingPeriod; falls back to candle derivation on failure. - New GET /api/finance/quotes batch endpoint feeds the watchlist; cards show last price + session, and 1D now uses the live day-change. - useVisibleInterval hook (ticker 15s, watchlist 20s) + shared MarketSessionBadge. Quote cache TTL 300s -> 30s so polls surface movement; the shared cache bounds upstream to ~1 fetch/symbol/window. - Tests: yahoo.test.ts covers session classification + meta parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 76f552c commit f6d8ee5

9 files changed

Lines changed: 519 additions & 51 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* GET /api/finance/quotes?symbols=NVDA,AAPL,…
3+
*
4+
* Batch last-price + session quotes for the watchlist cards (PRD §3.1). Paid-
5+
* gated; uses the per-profile market-data provider (connected broker → Yahoo
6+
* fallback) and the shared read-through quote cache so polling many symbols
7+
* stays cheap. Returns `{ quotes: { SYM: Quote } }`, omitting symbols with no data.
8+
*/
9+
10+
import { NextRequest, NextResponse } from 'next/server';
11+
import { requireActiveSubscription } from '@/lib/subscription/guard';
12+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
13+
import { type MarketDataProvider, type Quote } from '@/lib/finance/market-data';
14+
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
15+
import { readThrough, QUOTE_TTL_SECONDS } from '@/lib/finance/market-data/cache';
16+
import { parseSymbolList } from '@/lib/finance/watchlist';
17+
18+
export const dynamic = 'force-dynamic';
19+
20+
const MAX_SYMBOLS = 60;
21+
22+
async function quoteFor(provider: MarketDataProvider, symbol: string): Promise<Quote | null> {
23+
return readThrough<Quote | null>(symbol, `quote:${provider.id}`, QUOTE_TTL_SECONDS, () =>
24+
provider.getQuote(symbol),
25+
);
26+
}
27+
28+
export async function GET(request: NextRequest): Promise<NextResponse> {
29+
const gate = await requireActiveSubscription(request);
30+
if (gate) return gate;
31+
32+
const { valid } = parseSymbolList(request.nextUrl.searchParams.get('symbols') ?? '');
33+
if (valid.length === 0) return NextResponse.json({ quotes: {} });
34+
35+
const profileId = await getActiveProfileId();
36+
const provider = await getMarketDataProviderForProfile(profileId);
37+
38+
const symbols = valid.slice(0, MAX_SYMBOLS);
39+
const entries = await Promise.all(
40+
symbols.map(async (symbol): Promise<[string, Quote | null]> => {
41+
try {
42+
return [symbol, await quoteFor(provider, symbol)];
43+
} catch {
44+
return [symbol, null];
45+
}
46+
}),
47+
);
48+
49+
const quotes: Record<string, Quote> = {};
50+
for (const [symbol, quote] of entries) {
51+
if (quote) quotes[symbol] = quote;
52+
}
53+
54+
return NextResponse.json({ quotes });
55+
}

src/app/finance/finance-hub.tsx

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,21 @@ import Link from 'next/link';
1010
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
1111
import { BrokerConnect } from './broker-connect';
1212
import { Sparkline } from '@/components/finance/sparkline';
13+
import { MarketSessionBadge } from '@/components/finance/market-session';
14+
import { useVisibleInterval } from '@/lib/finance/use-visible-interval';
1315
import type { WatchlistChanges } from '@/lib/finance/performance';
16+
import type { Quote } from '@/lib/finance/market-data/types';
1417

1518
const RECENT_KEY = 'finance:recent';
1619

20+
/** Live-quote poll cadence (ms) for the watchlist while the tab is visible. */
21+
const QUOTE_POLL_MS = 20_000;
22+
23+
function formatPrice(value: number | undefined): string {
24+
if (value === undefined || !Number.isFinite(value)) return '—';
25+
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
26+
}
27+
1728
interface WatchlistRow {
1829
id: string;
1930
symbol: string;
@@ -43,6 +54,7 @@ export function FinanceHub(): React.ReactElement {
4354
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
4455
const [sparklines, setSparklines] = useState<Record<string, number[]>>({});
4556
const [changes, setChanges] = useState<Record<string, WatchlistChanges>>({});
57+
const [quotes, setQuotes] = useState<Record<string, Quote>>({});
4658

4759
useEffect(() => {
4860
try {
@@ -101,6 +113,27 @@ export function FinanceHub(): React.ReactElement {
101113
};
102114
}, [watchlistKey]);
103115

116+
// Live last price + market session per symbol — fetched on change and polled
117+
// while the tab is visible (see useVisibleInterval below).
118+
const loadQuotes = useCallback(() => {
119+
if (!watchlistKey) {
120+
setQuotes({});
121+
return;
122+
}
123+
fetch(`/api/finance/quotes?symbols=${encodeURIComponent(watchlistKey)}`, { cache: 'no-store' })
124+
.then((res) => (res.ok ? res.json() : { quotes: {} }))
125+
.then((body: { quotes?: Record<string, Quote> }) => setQuotes(body.quotes ?? {}))
126+
.catch(() => undefined);
127+
}, [watchlistKey]);
128+
129+
useEffect(() => {
130+
loadQuotes();
131+
}, [loadQuotes]);
132+
133+
// Stop polling when every symbol's session is closed (nothing moves).
134+
const anyLive = Object.values(quotes).some((q) => q.marketState && q.marketState !== 'CLOSED');
135+
useVisibleInterval(loadQuotes, QUOTE_POLL_MS, watchlistKey.length > 0 && anyLive);
136+
104137
const addBulk = useCallback(
105138
async (e: React.FormEvent) => {
106139
e.preventDefault();
@@ -216,20 +249,32 @@ export function FinanceHub(): React.ReactElement {
216249
</p>
217250
) : (
218251
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
219-
{watchlist.map((row) => (
220-
<Link key={row.id} href={`/finance/ticker/${row.symbol}`} className="card p-4 hover:bg-bg-tertiary">
221-
<div className="flex items-center justify-between gap-2">
222-
<div className="text-lg font-semibold text-text-primary">{row.symbol}</div>
223-
<Sparkline samples={sparklines[row.symbol]} width={56} />
224-
</div>
225-
{row.exchange ? <div className="text-xs text-text-muted">{row.exchange}</div> : null}
226-
<div className="mt-3 flex items-center justify-between gap-1 border-t border-border-primary pt-2">
227-
<PctChange label="1D" value={changes[row.symbol]?.d1 ?? null} />
228-
<PctChange label="5D" value={changes[row.symbol]?.d5 ?? null} />
229-
<PctChange label="30D" value={changes[row.symbol]?.d30 ?? null} />
230-
</div>
231-
</Link>
232-
))}
252+
{watchlist.map((row) => {
253+
const quote = quotes[row.symbol];
254+
// Prefer the live intraday day-change for 1D; fall back to the
255+
// candle-derived trailing change before the quote loads.
256+
const d1 = quote ? quote.changePercent : changes[row.symbol]?.d1 ?? null;
257+
return (
258+
<Link key={row.id} href={`/finance/ticker/${row.symbol}`} className="card p-4 hover:bg-bg-tertiary">
259+
<div className="flex items-center justify-between gap-2">
260+
<div className="text-lg font-semibold text-text-primary">{row.symbol}</div>
261+
<Sparkline samples={sparklines[row.symbol]} width={56} />
262+
</div>
263+
<div className="mt-1 flex items-center justify-between gap-2">
264+
<span className="text-base font-semibold tabular-nums text-text-primary">
265+
{formatPrice(quote?.price)}
266+
</span>
267+
<MarketSessionBadge state={quote?.marketState} />
268+
</div>
269+
{row.exchange ? <div className="text-xs text-text-muted">{row.exchange}</div> : null}
270+
<div className="mt-3 flex items-center justify-between gap-1 border-t border-border-primary pt-2">
271+
<PctChange label="1D" value={d1} />
272+
<PctChange label="5D" value={changes[row.symbol]?.d5 ?? null} />
273+
<PctChange label="30D" value={changes[row.symbol]?.d30 ?? null} />
274+
</div>
275+
</Link>
276+
);
277+
})}
233278
</div>
234279
)}
235280
</section>

src/app/finance/ticker/[ticker]/ticker-view.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { NewsSection } from '@/components/news';
1717
// never pulled into the client bundle.
1818
import { TICKER_RANGES, type AssetInfo, type Candle, type Fundamentals, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
1919
import type { WatchlistChanges } from '@/lib/finance/performance';
20+
import { useVisibleInterval } from '@/lib/finance/use-visible-interval';
21+
import { MarketSessionBadge } from '@/components/finance/market-session';
22+
23+
/** Quote poll cadence (ms) while the tab is visible and a session is live. */
24+
const QUOTE_POLL_MS = 15_000;
2025

2126
const RECENT_KEY = 'finance:recent';
2227
const RECENT_MAX = 12;
@@ -68,20 +73,26 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
6873
rememberRecent(symbol);
6974
}, [symbol]);
7075

71-
// Quote (independent of range).
72-
useEffect(() => {
73-
let cancelled = false;
76+
// Quote (independent of range). Fetched on mount and then polled live while
77+
// the tab is visible — see the useVisibleInterval below.
78+
const loadQuote = useCallback(() => {
7479
fetch(`/api/finance/quote?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
7580
.then((res) => (res.ok ? res.json() : null))
7681
.then((body) => {
77-
if (!cancelled && body?.quote) setQuote(body.quote as Quote);
82+
if (body?.quote) setQuote(body.quote as Quote);
7883
})
7984
.catch(() => undefined);
80-
return () => {
81-
cancelled = true;
82-
};
8385
}, [symbol]);
8486

87+
useEffect(() => {
88+
setQuote(null);
89+
loadQuote();
90+
}, [loadQuote]);
91+
92+
// Poll the quote for near-live price/stats. Pause once we know the market is
93+
// closed (the numbers can't move), but keep polling pre/regular/after-hours.
94+
useVisibleInterval(loadQuote, QUOTE_POLL_MS, quote?.marketState !== 'CLOSED');
95+
8596
// Candles (re-fetched per range).
8697
useEffect(() => {
8798
let cancelled = false;
@@ -223,7 +234,7 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
223234
{asset.exchange ? <span className="text-text-muted"> · {asset.exchange}</span> : null}
224235
</div>
225236
) : null}
226-
{quote ? <div className="mt-2 flex items-baseline gap-3">
237+
{quote ? <div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
227238
<span className="text-2xl font-semibold text-text-primary">
228239
${formatNumber(quote.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
229240
</span>
@@ -232,6 +243,7 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
232243
{formatNumber(quote.change, { maximumFractionDigits: 2 })} ({changePositive ? '+' : ''}
233244
{formatNumber(quote.changePercent, { maximumFractionDigits: 2 })}%)
234245
</span>
246+
<MarketSessionBadge state={quote.marketState} className="self-center" />
235247
</div> : null}
236248
<div className="mt-3 flex items-center gap-5">
237249
<TrailingChange label="1D" value={changes?.d1 ?? null} />
@@ -280,7 +292,7 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
280292
<Stat label="Volume" value={formatVolume(quote?.volume)} />
281293
<Stat
282294
label="As of"
283-
value={quote ? new Date(quote.asOf * 1000).toLocaleDateString() : '—'}
295+
value={quote ? new Date(quote.asOf * 1000).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) : '—'}
284296
/>
285297
</div>
286298

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Finance — small market-session badge (pre-market / open / after-hours /
3+
* closed). Shared by the watchlist cards and the ticker page so the label and
4+
* colors stay consistent.
5+
*/
6+
7+
import type { MarketSession } from '@/lib/finance/market-data/types';
8+
9+
const LABELS: Record<MarketSession, string> = {
10+
PRE: 'Pre-market',
11+
REGULAR: 'Market open',
12+
POST: 'After hours',
13+
CLOSED: 'Closed',
14+
};
15+
16+
const STYLES: Record<MarketSession, string> = {
17+
PRE: 'bg-amber-500/15 text-amber-300',
18+
REGULAR: 'bg-green-500/15 text-green-300',
19+
POST: 'bg-indigo-500/15 text-indigo-300',
20+
CLOSED: 'bg-bg-tertiary text-text-muted',
21+
};
22+
23+
export function marketSessionLabel(state: MarketSession | undefined): string | null {
24+
return state ? LABELS[state] : null;
25+
}
26+
27+
export function MarketSessionBadge({
28+
state,
29+
className = '',
30+
}: {
31+
state: MarketSession | undefined;
32+
className?: string;
33+
}): React.ReactElement | null {
34+
if (!state) return null;
35+
const live = state !== 'CLOSED';
36+
return (
37+
<span
38+
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${STYLES[state]} ${className}`}
39+
>
40+
{live ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}
41+
{LABELS[state]}
42+
</span>
43+
);
44+
}

src/lib/finance/market-data/cache.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import type { Json } from '@/lib/supabase';
1111

1212
const CACHE_TABLE = 'finance_quotes_cache';
1313

14-
/** Default freshness windows (seconds). EOD data changes at most daily. */
15-
export const QUOTE_TTL_SECONDS = 5 * 60;
14+
/**
15+
* Default freshness windows (seconds). Quotes carry an intraday last price +
16+
* session, so the window is short to feel near-live; the shared cache bounds
17+
* upstream load to ~1 fetch per symbol per window regardless of viewer count.
18+
*/
19+
export const QUOTE_TTL_SECONDS = 30;
1620
export const CANDLES_TTL_SECONDS = 60 * 60;
1721
/** Fundamentals (valuation/perf/technicals) move slowly — cache for hours. */
1822
export const FUNDAMENTALS_TTL_SECONDS = 6 * 60 * 60;

src/lib/finance/market-data/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface Candle {
2424
volume: number;
2525
}
2626

27+
/**
28+
* Trading session for a symbol's primary exchange:
29+
* PRE = pre-market, REGULAR = regular hours, POST = after-hours, CLOSED = no
30+
* session active. Providers that don't expose it leave `marketState` unset.
31+
*/
32+
export type MarketSession = 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
33+
2734
/** Last price + the key stats we can render defensively (PRD §3.2). */
2835
export interface Quote {
2936
symbol: string;
@@ -37,6 +44,8 @@ export interface Quote {
3744
volume: number;
3845
/** UTC unix seconds of the latest bar backing this quote. */
3946
asOf: number;
47+
/** Current trading session, when the provider exposes it. */
48+
marketState?: MarketSession;
4049
}
4150

4251
/** Symbol typeahead result (PRD §3.1). */

0 commit comments

Comments
 (0)