Skip to content

Commit d72e568

Browse files
ralyodioclaude
andauthored
feat(finance): watchlist 1/5/30-day % changes, ticker news + company info (#112)
- performance.ts: trailing 1/5/30-day % change helper (calendar-day lookback, nearest-prior-trading-day; falls back to oldest bar on short history) - /api/finance/watchlist/changes: per-profile changes endpoint (no fallback) - finance-hub: 1D/5D/30D % on watchlist cards - ticker view: 1D/5D/30D % in the price header, "About" company section, and a /news-backed "News for {symbol}" section - market-data: AssetInfo + optional getAsset() on the provider interface; Alpaca implements it via the assets endpoint, Finnhub delegates to it - /api/finance/asset: paid-gated, per-profile, read-through cached Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7a4586c commit d72e568

10 files changed

Lines changed: 492 additions & 1 deletion

File tree

src/app/api/finance/asset/route.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* GET /api/finance/asset?symbol=NVDA
3+
*
4+
* Company / asset metadata for the ticker page (name, exchange, class, and the
5+
* Alpaca tradability flags). Paid-gated; resolves the per-profile provider
6+
* (connected Alpaca → app default) and read-through caches the result.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { requireActiveSubscription } from '@/lib/subscription/guard';
11+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
12+
import { getActiveBrokerCreds } from '@/lib/finance/brokers/service';
13+
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
14+
import { readThrough, QUOTE_TTL_SECONDS } from '@/lib/finance/market-data/cache';
15+
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
16+
import type { AssetInfo } from '@/lib/finance/market-data/types';
17+
18+
export const dynamic = 'force-dynamic';
19+
20+
const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/;
21+
22+
export async function GET(request: NextRequest): Promise<NextResponse> {
23+
const gate = await requireActiveSubscription(request);
24+
if (gate) return gate;
25+
26+
const symbol = normalizeSymbol(request.nextUrl.searchParams.get('symbol') ?? '');
27+
if (!SYMBOL_RE.test(symbol)) {
28+
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
29+
}
30+
31+
const profileId = await getActiveProfileId();
32+
const provider = await getMarketDataProviderForProfile(profileId);
33+
if (typeof provider.getAsset !== 'function') {
34+
return NextResponse.json({ asset: null });
35+
}
36+
37+
// Asset metadata is account-agnostic, but a connected broker's keys can serve
38+
// it; vary the cache key by whether per-profile creds back the lookup.
39+
const hasBroker = profileId ? Boolean(await getActiveBrokerCreds(profileId, 'alpaca')) : false;
40+
const cacheKey = `asset:${provider.id}:${hasBroker ? 'broker' : 'app'}`;
41+
42+
try {
43+
const asset = await readThrough<AssetInfo | null>(symbol, cacheKey, QUOTE_TTL_SECONDS, () =>
44+
provider.getAsset!(symbol),
45+
);
46+
return NextResponse.json({ asset });
47+
} catch {
48+
return NextResponse.json({ asset: null });
49+
}
50+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* GET /api/finance/watchlist/changes?symbols=NVDA,AAPL,…
3+
*
4+
* Returns trailing 1 / 5 / 30-day percent gain/loss per symbol for the watchlist
5+
* cards. Paid-gated; uses the per-profile market-data provider (connected
6+
* broker → Yahoo fallback) and read-through caches each symbol's 1M candles.
7+
*/
8+
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { requireActiveSubscription } from '@/lib/subscription/guard';
11+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
12+
import { type MarketDataProvider } from '@/lib/finance/market-data';
13+
import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile';
14+
import { readThrough, CANDLES_TTL_SECONDS } from '@/lib/finance/market-data/cache';
15+
import { parseSymbolList } from '@/lib/finance/watchlist';
16+
import { computeChanges, EMPTY_CHANGES, type WatchlistChanges } from '@/lib/finance/performance';
17+
18+
export const dynamic = 'force-dynamic';
19+
20+
const MAX_SYMBOLS = 60;
21+
22+
async function changesFor(provider: MarketDataProvider, symbol: string): Promise<WatchlistChanges> {
23+
return readThrough<WatchlistChanges>(symbol, `changes:${provider.id}`, CANDLES_TTL_SECONDS, async () => {
24+
const candles = await provider.getCandles(symbol, '1M');
25+
return computeChanges(candles);
26+
});
27+
}
28+
29+
export async function GET(request: NextRequest): Promise<NextResponse> {
30+
const gate = await requireActiveSubscription(request);
31+
if (gate) return gate;
32+
33+
const { valid } = parseSymbolList(request.nextUrl.searchParams.get('symbols') ?? '');
34+
if (valid.length === 0) return NextResponse.json({ changes: {} });
35+
36+
const profileId = await getActiveProfileId();
37+
const provider = await getMarketDataProviderForProfile(profileId);
38+
39+
const symbols = valid.slice(0, MAX_SYMBOLS);
40+
const entries = await Promise.all(
41+
symbols.map(async (symbol): Promise<[string, WatchlistChanges]> => {
42+
try {
43+
return [symbol, await changesFor(provider, symbol)];
44+
} catch {
45+
return [symbol, EMPTY_CHANGES];
46+
}
47+
}),
48+
);
49+
50+
return NextResponse.json({ changes: Object.fromEntries(entries) });
51+
}

src/app/finance/finance-hub.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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 type { WatchlistChanges } from '@/lib/finance/performance';
1314

1415
const RECENT_KEY = 'finance:recent';
1516

@@ -19,6 +20,19 @@ interface WatchlistRow {
1920
exchange: string | null;
2021
}
2122

23+
function PctChange({ label, value }: { label: string; value: number | null }): React.ReactElement {
24+
const known = value !== null && Number.isFinite(value);
25+
const up = known && (value as number) >= 0;
26+
const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400';
27+
const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`;
28+
return (
29+
<div className="flex flex-col items-center leading-tight">
30+
<span className="text-[10px] uppercase tracking-wide text-text-muted">{label}</span>
31+
<span className={`text-xs font-medium tabular-nums ${color}`}>{text}</span>
32+
</div>
33+
);
34+
}
35+
2236
export function FinanceHub(): React.ReactElement {
2337
const router = useRouter();
2438
const [query, setQuery] = useState('');
@@ -28,6 +42,7 @@ export function FinanceHub(): React.ReactElement {
2842
const [bulkBusy, setBulkBusy] = useState(false);
2943
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
3044
const [sparklines, setSparklines] = useState<Record<string, number[]>>({});
45+
const [changes, setChanges] = useState<Record<string, WatchlistChanges>>({});
3146

3247
useEffect(() => {
3348
try {
@@ -68,6 +83,24 @@ export function FinanceHub(): React.ReactElement {
6883
};
6984
}, [watchlistKey]);
7085

86+
// Fetch trailing 1/5/30-day % changes for the watchlist symbols.
87+
useEffect(() => {
88+
if (!watchlistKey) {
89+
setChanges({});
90+
return;
91+
}
92+
let cancelled = false;
93+
fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(watchlistKey)}`, { cache: 'no-store' })
94+
.then((res) => (res.ok ? res.json() : { changes: {} }))
95+
.then((body: { changes?: Record<string, WatchlistChanges> }) => {
96+
if (!cancelled) setChanges(body.changes ?? {});
97+
})
98+
.catch(() => undefined);
99+
return () => {
100+
cancelled = true;
101+
};
102+
}, [watchlistKey]);
103+
71104
const addBulk = useCallback(
72105
async (e: React.FormEvent) => {
73106
e.preventDefault();
@@ -190,6 +223,11 @@ export function FinanceHub(): React.ReactElement {
190223
<Sparkline samples={sparklines[row.symbol]} width={56} />
191224
</div>
192225
{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>
193231
</Link>
194232
))}
195233
</div>

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
import { useCallback, useEffect, useState } from 'react';
1313
import { FinanceChart } from './finance-chart';
1414
import { ReportPanel } from './report-panel';
15+
import { NewsSection } from '@/components/news';
1516
// Import from the SDK-free `types` module (not the index) so the Alpaca SDK is
1617
// never pulled into the client bundle.
17-
import { TICKER_RANGES, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
18+
import { TICKER_RANGES, type AssetInfo, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
19+
import type { WatchlistChanges } from '@/lib/finance/performance';
1820

1921
const RECENT_KEY = 'finance:recent';
2022
const RECENT_MAX = 12;
@@ -58,6 +60,8 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
5860
const [error, setError] = useState<string | null>(null);
5961
const [inWatchlist, setInWatchlist] = useState<boolean | null>(null);
6062
const [holding, setHolding] = useState<Holding | null>(null);
63+
const [changes, setChanges] = useState<WatchlistChanges | null>(null);
64+
const [asset, setAsset] = useState<AssetInfo | null>(null);
6165

6266
useEffect(() => {
6367
rememberRecent(symbol);
@@ -132,6 +136,36 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
132136
};
133137
}, [symbol]);
134138

139+
// Trailing 1/5/30-day % change (reuses the watchlist changes endpoint).
140+
useEffect(() => {
141+
let cancelled = false;
142+
setChanges(null);
143+
fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
144+
.then((res) => (res.ok ? res.json() : { changes: {} }))
145+
.then((body: { changes?: Record<string, WatchlistChanges> }) => {
146+
if (!cancelled) setChanges(body.changes?.[symbol] ?? null);
147+
})
148+
.catch(() => undefined);
149+
return () => {
150+
cancelled = true;
151+
};
152+
}, [symbol]);
153+
154+
// Company / asset metadata (Alpaca assets endpoint).
155+
useEffect(() => {
156+
let cancelled = false;
157+
setAsset(null);
158+
fetch(`/api/finance/asset?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
159+
.then((res) => (res.ok ? res.json() : { asset: null }))
160+
.then((body: { asset?: AssetInfo | null }) => {
161+
if (!cancelled) setAsset(body.asset ?? null);
162+
})
163+
.catch(() => undefined);
164+
return () => {
165+
cancelled = true;
166+
};
167+
}, [symbol]);
168+
135169
const toggleWatchlist = useCallback(async () => {
136170
const next = !inWatchlist;
137171
setInWatchlist(next);
@@ -167,6 +201,12 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
167201
{inWatchlist ? '★ In watchlist' : '☆ Add to watchlist'}
168202
</button>
169203
</div>
204+
{asset?.name ? (
205+
<div className="mt-1 text-sm text-text-secondary">
206+
{asset.name}
207+
{asset.exchange ? <span className="text-text-muted"> · {asset.exchange}</span> : null}
208+
</div>
209+
) : null}
170210
{quote ? <div className="mt-2 flex items-baseline gap-3">
171211
<span className="text-2xl font-semibold text-text-primary">
172212
${formatNumber(quote.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -177,6 +217,11 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
177217
{formatNumber(quote.changePercent, { maximumFractionDigits: 2 })}%)
178218
</span>
179219
</div> : null}
220+
<div className="mt-3 flex items-center gap-5">
221+
<TrailingChange label="1D" value={changes?.d1 ?? null} />
222+
<TrailingChange label="5D" value={changes?.d5 ?? null} />
223+
<TrailingChange label="30D" value={changes?.d30 ?? null} />
224+
</div>
180225
</div>
181226
<div className="flex flex-wrap gap-1">
182227
{TICKER_RANGES.map((r) => (
@@ -223,8 +268,35 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
223268
/>
224269
</div>
225270

271+
{/* Company info from the broker (Alpaca) assets endpoint. */}
272+
{asset ? (
273+
<section className="mt-8">
274+
<h2 className="mb-3 text-lg font-semibold text-text-primary">About {symbol}</h2>
275+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
276+
{asset.name ? <Stat label="Name" value={asset.name} /> : null}
277+
{asset.exchange ? <Stat label="Exchange" value={asset.exchange} /> : null}
278+
{asset.assetClass ? <Stat label="Class" value={formatAssetClass(asset.assetClass)} /> : null}
279+
{asset.status ? <Stat label="Status" value={capitalize(asset.status)} /> : null}
280+
<Stat label="Tradable" value={formatBool(asset.tradable)} />
281+
<Stat label="Fractionable" value={formatBool(asset.fractionable)} />
282+
<Stat label="Marginable" value={formatBool(asset.marginable)} />
283+
<Stat label="Shortable" value={formatBool(asset.shortable)} />
284+
<Stat label="Easy to borrow" value={formatBool(asset.easyToBorrow)} />
285+
{asset.hasOptions !== null ? <Stat label="Options" value={formatBool(asset.hasOptions)} /> : null}
286+
</div>
287+
</section>
288+
) : null}
289+
226290
{/* AI report area — never auto-runs; the Analyze button is the cost boundary. */}
227291
<ReportPanel symbol={symbol} />
292+
293+
{/* Ticker news — pulls from our /news API, searched by symbol. */}
294+
<section className="mt-8">
295+
<h2 className="mb-3 text-lg font-semibold text-text-primary">
296+
News for {symbol}
297+
</h2>
298+
<NewsSection searchTerm={symbol} limit={10} />
299+
</section>
228300
</div>
229301
);
230302
}
@@ -237,3 +309,32 @@ function Stat({ label, value }: { label: string; value: string }): React.ReactEl
237309
</div>
238310
);
239311
}
312+
313+
function TrailingChange({ label, value }: { label: string; value: number | null }): React.ReactElement {
314+
const known = value !== null && Number.isFinite(value);
315+
const up = known && (value as number) >= 0;
316+
const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400';
317+
const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`;
318+
return (
319+
<div className="flex items-baseline gap-1.5">
320+
<span className="text-xs uppercase tracking-wider text-text-muted">{label}</span>
321+
<span className={`text-sm font-semibold tabular-nums ${color}`}>{text}</span>
322+
</div>
323+
);
324+
}
325+
326+
function formatBool(value: boolean | null): string {
327+
if (value === null) return '—';
328+
return value ? 'Yes' : 'No';
329+
}
330+
331+
function capitalize(value: string): string {
332+
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
333+
}
334+
335+
function formatAssetClass(value: string): string {
336+
return value
337+
.split('_')
338+
.map((part) => (part === 'us' ? 'US' : capitalize(part)))
339+
.join(' ');
340+
}

src/lib/finance/market-data/alpaca.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,51 @@ describe('AlpacaMarketDataProvider', () => {
6565
const p = new AlpacaMarketDataProvider({ clientFactory: () => makeClient(BARS) });
6666
expect(await p.search()).toEqual([]);
6767
});
68+
69+
it('maps asset metadata from getAsset', async () => {
70+
const client: AlpacaDataClient = {
71+
...makeClient(BARS),
72+
getAsset: async (symbol: string) => ({
73+
symbol,
74+
name: 'NVIDIA Corporation',
75+
exchange: 'NASDAQ',
76+
class: 'us_equity',
77+
status: 'active',
78+
tradable: true,
79+
marginable: true,
80+
shortable: true,
81+
easy_to_borrow: true,
82+
fractionable: true,
83+
attributes: ['options_enabled'],
84+
}),
85+
};
86+
const p = new AlpacaMarketDataProvider({ clientFactory: () => client });
87+
const asset = await p.getAsset('nvda');
88+
expect(asset).toMatchObject({
89+
symbol: 'NVDA',
90+
name: 'NVIDIA Corporation',
91+
exchange: 'NASDAQ',
92+
assetClass: 'us_equity',
93+
status: 'active',
94+
tradable: true,
95+
fractionable: true,
96+
hasOptions: true,
97+
});
98+
});
99+
100+
it('returns null asset when the SDK lacks getAsset', async () => {
101+
const p = new AlpacaMarketDataProvider({ clientFactory: () => makeClient(BARS) });
102+
expect(await p.getAsset('nvda')).toBeNull();
103+
});
104+
105+
it('returns null asset when getAsset throws', async () => {
106+
const client: AlpacaDataClient = {
107+
...makeClient(BARS),
108+
getAsset: async () => {
109+
throw new Error('not found');
110+
},
111+
};
112+
const p = new AlpacaMarketDataProvider({ clientFactory: () => client });
113+
expect(await p.getAsset('zzzz')).toBeNull();
114+
});
68115
});

0 commit comments

Comments
 (0)