From 6f9f18c29bccc331aa66c42b18b17717d7a2a032 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 17 Jun 2026 20:04:45 +0000 Subject: [PATCH 1/2] feat(finance): watchlist 1/5/30-day % changes, ticker news + company info - 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 --- src/app/api/finance/asset/route.ts | 50 +++++++++ .../api/finance/watchlist/changes/route.ts | 51 +++++++++ src/app/finance/finance-hub.tsx | 38 +++++++ .../finance/ticker/[ticker]/ticker-view.tsx | 103 +++++++++++++++++- src/lib/finance/market-data/alpaca.test.ts | 47 ++++++++ src/lib/finance/market-data/alpaca.ts | 45 ++++++++ src/lib/finance/market-data/finnhub.ts | 6 + src/lib/finance/market-data/types.ts | 24 ++++ src/lib/finance/performance.test.ts | 73 +++++++++++++ src/lib/finance/performance.ts | 56 ++++++++++ 10 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 src/app/api/finance/asset/route.ts create mode 100644 src/app/api/finance/watchlist/changes/route.ts create mode 100644 src/lib/finance/performance.test.ts create mode 100644 src/lib/finance/performance.ts diff --git a/src/app/api/finance/asset/route.ts b/src/app/api/finance/asset/route.ts new file mode 100644 index 0000000..43c916b --- /dev/null +++ b/src/app/api/finance/asset/route.ts @@ -0,0 +1,50 @@ +/** + * GET /api/finance/asset?symbol=NVDA + * + * Company / asset metadata for the ticker page (name, exchange, class, and the + * Alpaca tradability flags). Paid-gated; resolves the per-profile provider + * (connected Alpaca → app default) and read-through caches the result. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireActiveSubscription } from '@/lib/subscription/guard'; +import { getActiveProfileId } from '@/lib/profiles/profile-utils'; +import { getActiveBrokerCreds } from '@/lib/finance/brokers/service'; +import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile'; +import { readThrough, QUOTE_TTL_SECONDS } from '@/lib/finance/market-data/cache'; +import { normalizeSymbol } from '@/lib/finance/market-data/stooq'; +import type { AssetInfo } from '@/lib/finance/market-data/types'; + +export const dynamic = 'force-dynamic'; + +const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/; + +export async function GET(request: NextRequest): Promise { + const gate = await requireActiveSubscription(request); + if (gate) return gate; + + const symbol = normalizeSymbol(request.nextUrl.searchParams.get('symbol') ?? ''); + if (!SYMBOL_RE.test(symbol)) { + return NextResponse.json({ error: 'invalid symbol' }, { status: 400 }); + } + + const profileId = await getActiveProfileId(); + const provider = await getMarketDataProviderForProfile(profileId); + if (typeof provider.getAsset !== 'function') { + return NextResponse.json({ asset: null }); + } + + // Asset metadata is account-agnostic, but a connected broker's keys can serve + // it; vary the cache key by whether per-profile creds back the lookup. + const hasBroker = profileId ? Boolean(await getActiveBrokerCreds(profileId, 'alpaca')) : false; + const cacheKey = `asset:${provider.id}:${hasBroker ? 'broker' : 'app'}`; + + try { + const asset = await readThrough(symbol, cacheKey, QUOTE_TTL_SECONDS, () => + provider.getAsset!(symbol), + ); + return NextResponse.json({ asset }); + } catch { + return NextResponse.json({ asset: null }); + } +} diff --git a/src/app/api/finance/watchlist/changes/route.ts b/src/app/api/finance/watchlist/changes/route.ts new file mode 100644 index 0000000..40e50a4 --- /dev/null +++ b/src/app/api/finance/watchlist/changes/route.ts @@ -0,0 +1,51 @@ +/** + * GET /api/finance/watchlist/changes?symbols=NVDA,AAPL,… + * + * Returns trailing 1 / 5 / 30-day percent gain/loss per symbol for the watchlist + * cards. Paid-gated; uses the per-profile market-data provider (connected + * broker → Yahoo fallback) and read-through caches each symbol's 1M candles. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireActiveSubscription } from '@/lib/subscription/guard'; +import { getActiveProfileId } from '@/lib/profiles/profile-utils'; +import { type MarketDataProvider } from '@/lib/finance/market-data'; +import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile'; +import { readThrough, CANDLES_TTL_SECONDS } from '@/lib/finance/market-data/cache'; +import { parseSymbolList } from '@/lib/finance/watchlist'; +import { computeChanges, EMPTY_CHANGES, type WatchlistChanges } from '@/lib/finance/performance'; + +export const dynamic = 'force-dynamic'; + +const MAX_SYMBOLS = 60; + +async function changesFor(provider: MarketDataProvider, symbol: string): Promise { + return readThrough(symbol, `changes:${provider.id}`, CANDLES_TTL_SECONDS, async () => { + const candles = await provider.getCandles(symbol, '1M'); + return computeChanges(candles); + }); +} + +export async function GET(request: NextRequest): Promise { + const gate = await requireActiveSubscription(request); + if (gate) return gate; + + const { valid } = parseSymbolList(request.nextUrl.searchParams.get('symbols') ?? ''); + if (valid.length === 0) return NextResponse.json({ changes: {} }); + + const profileId = await getActiveProfileId(); + const provider = await getMarketDataProviderForProfile(profileId); + + const symbols = valid.slice(0, MAX_SYMBOLS); + const entries = await Promise.all( + symbols.map(async (symbol): Promise<[string, WatchlistChanges]> => { + try { + return [symbol, await changesFor(provider, symbol)]; + } catch { + return [symbol, EMPTY_CHANGES]; + } + }), + ); + + return NextResponse.json({ changes: Object.fromEntries(entries) }); +} diff --git a/src/app/finance/finance-hub.tsx b/src/app/finance/finance-hub.tsx index 1cd045b..6a03c7f 100644 --- a/src/app/finance/finance-hub.tsx +++ b/src/app/finance/finance-hub.tsx @@ -10,6 +10,7 @@ import Link from 'next/link'; import { normalizeSymbol } from '@/lib/finance/market-data/stooq'; import { BrokerConnect } from './broker-connect'; import { Sparkline } from '@/components/finance/sparkline'; +import type { WatchlistChanges } from '@/lib/finance/performance'; const RECENT_KEY = 'finance:recent'; @@ -19,6 +20,19 @@ interface WatchlistRow { exchange: string | null; } +function PctChange({ label, value }: { label: string; value: number | null }): React.ReactElement { + const known = value !== null && Number.isFinite(value); + const up = known && (value as number) >= 0; + const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400'; + const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`; + return ( +
+ {label} + {text} +
+ ); +} + export function FinanceHub(): React.ReactElement { const router = useRouter(); const [query, setQuery] = useState(''); @@ -28,6 +42,7 @@ export function FinanceHub(): React.ReactElement { const [bulkBusy, setBulkBusy] = useState(false); const [bulkMsg, setBulkMsg] = useState(null); const [sparklines, setSparklines] = useState>({}); + const [changes, setChanges] = useState>({}); useEffect(() => { try { @@ -68,6 +83,24 @@ export function FinanceHub(): React.ReactElement { }; }, [watchlistKey]); + // Fetch trailing 1/5/30-day % changes for the watchlist symbols. + useEffect(() => { + if (!watchlistKey) { + setChanges({}); + return; + } + let cancelled = false; + fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(watchlistKey)}`, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : { changes: {} })) + .then((body: { changes?: Record }) => { + if (!cancelled) setChanges(body.changes ?? {}); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [watchlistKey]); + const addBulk = useCallback( async (e: React.FormEvent) => { e.preventDefault(); @@ -190,6 +223,11 @@ export function FinanceHub(): React.ReactElement { {row.exchange ?
{row.exchange}
: null} +
+ + + +
))} diff --git a/src/app/finance/ticker/[ticker]/ticker-view.tsx b/src/app/finance/ticker/[ticker]/ticker-view.tsx index 07e68df..aee0286 100644 --- a/src/app/finance/ticker/[ticker]/ticker-view.tsx +++ b/src/app/finance/ticker/[ticker]/ticker-view.tsx @@ -12,9 +12,11 @@ import { useCallback, useEffect, useState } from 'react'; import { FinanceChart } from './finance-chart'; import { ReportPanel } from './report-panel'; +import { NewsSection } from '@/components/news'; // Import from the SDK-free `types` module (not the index) so the Alpaca SDK is // never pulled into the client bundle. -import { TICKER_RANGES, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types'; +import { TICKER_RANGES, type AssetInfo, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types'; +import type { WatchlistChanges } from '@/lib/finance/performance'; const RECENT_KEY = 'finance:recent'; const RECENT_MAX = 12; @@ -58,6 +60,8 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement { const [error, setError] = useState(null); const [inWatchlist, setInWatchlist] = useState(null); const [holding, setHolding] = useState(null); + const [changes, setChanges] = useState(null); + const [asset, setAsset] = useState(null); useEffect(() => { rememberRecent(symbol); @@ -132,6 +136,36 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement { }; }, [symbol]); + // Trailing 1/5/30-day % change (reuses the watchlist changes endpoint). + useEffect(() => { + let cancelled = false; + setChanges(null); + fetch(`/api/finance/watchlist/changes?symbols=${encodeURIComponent(symbol)}`, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : { changes: {} })) + .then((body: { changes?: Record }) => { + if (!cancelled) setChanges(body.changes?.[symbol] ?? null); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [symbol]); + + // Company / asset metadata (Alpaca assets endpoint). + useEffect(() => { + let cancelled = false; + setAsset(null); + fetch(`/api/finance/asset?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : { asset: null })) + .then((body: { asset?: AssetInfo | null }) => { + if (!cancelled) setAsset(body.asset ?? null); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [symbol]); + const toggleWatchlist = useCallback(async () => { const next = !inWatchlist; setInWatchlist(next); @@ -167,6 +201,12 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement { {inWatchlist ? '★ In watchlist' : '☆ Add to watchlist'} + {asset?.name ? ( +
+ {asset.name} + {asset.exchange ? · {asset.exchange} : null} +
+ ) : null} {quote ?
${formatNumber(quote.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} @@ -177,6 +217,11 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement { {formatNumber(quote.changePercent, { maximumFractionDigits: 2 })}%)
: null} +
+ + + +
{TICKER_RANGES.map((r) => ( @@ -223,8 +268,35 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement { />
+ {/* Company info from the broker (Alpaca) assets endpoint. */} + {asset ? ( +
+

About {symbol}

+
+ {asset.name ? : null} + {asset.exchange ? : null} + {asset.assetClass ? : null} + {asset.status ? : null} + + + + + + {asset.hasOptions !== null ? : null} +
+
+ ) : null} + {/* AI report area — never auto-runs; the Analyze button is the cost boundary. */} + + {/* Ticker news — pulls from our /news API, searched by symbol. */} +
+

+ News for {symbol} +

+ +
); } @@ -237,3 +309,32 @@ function Stat({ label, value }: { label: string; value: string }): React.ReactEl ); } + +function TrailingChange({ label, value }: { label: string; value: number | null }): React.ReactElement { + const known = value !== null && Number.isFinite(value); + const up = known && (value as number) >= 0; + const color = !known ? 'text-text-muted' : up ? 'text-green-400' : 'text-red-400'; + const text = !known ? '—' : `${up ? '+' : ''}${(value as number).toFixed(2)}%`; + return ( +
+ {label} + {text} +
+ ); +} + +function formatBool(value: boolean | null): string { + if (value === null) return '—'; + return value ? 'Yes' : 'No'; +} + +function capitalize(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + +function formatAssetClass(value: string): string { + return value + .split('_') + .map((part) => (part === 'us' ? 'US' : capitalize(part))) + .join(' '); +} diff --git a/src/lib/finance/market-data/alpaca.test.ts b/src/lib/finance/market-data/alpaca.test.ts index 7f6963c..cc6f9db 100644 --- a/src/lib/finance/market-data/alpaca.test.ts +++ b/src/lib/finance/market-data/alpaca.test.ts @@ -65,4 +65,51 @@ describe('AlpacaMarketDataProvider', () => { const p = new AlpacaMarketDataProvider({ clientFactory: () => makeClient(BARS) }); expect(await p.search()).toEqual([]); }); + + it('maps asset metadata from getAsset', async () => { + const client: AlpacaDataClient = { + ...makeClient(BARS), + getAsset: async (symbol: string) => ({ + symbol, + name: 'NVIDIA Corporation', + exchange: 'NASDAQ', + class: 'us_equity', + status: 'active', + tradable: true, + marginable: true, + shortable: true, + easy_to_borrow: true, + fractionable: true, + attributes: ['options_enabled'], + }), + }; + const p = new AlpacaMarketDataProvider({ clientFactory: () => client }); + const asset = await p.getAsset('nvda'); + expect(asset).toMatchObject({ + symbol: 'NVDA', + name: 'NVIDIA Corporation', + exchange: 'NASDAQ', + assetClass: 'us_equity', + status: 'active', + tradable: true, + fractionable: true, + hasOptions: true, + }); + }); + + it('returns null asset when the SDK lacks getAsset', async () => { + const p = new AlpacaMarketDataProvider({ clientFactory: () => makeClient(BARS) }); + expect(await p.getAsset('nvda')).toBeNull(); + }); + + it('returns null asset when getAsset throws', async () => { + const client: AlpacaDataClient = { + ...makeClient(BARS), + getAsset: async () => { + throw new Error('not found'); + }, + }; + const p = new AlpacaMarketDataProvider({ clientFactory: () => client }); + expect(await p.getAsset('zzzz')).toBeNull(); + }); }); diff --git a/src/lib/finance/market-data/alpaca.ts b/src/lib/finance/market-data/alpaca.ts index 3e40bcc..a5a2ec8 100644 --- a/src/lib/finance/market-data/alpaca.ts +++ b/src/lib/finance/market-data/alpaca.ts @@ -10,6 +10,7 @@ import Alpaca from '@alpacahq/alpaca-trade-api'; import { + type AssetInfo, type Candle, type MarketDataProvider, type Quote, @@ -28,12 +29,29 @@ interface AlpacaBarRaw { Volume?: number; } +/** Raw shape returned by Alpaca's `GET /v2/assets/{symbol}`. */ +export interface AlpacaAssetRaw { + symbol?: string; + name?: string; + exchange?: string; + class?: string; + status?: string; + tradable?: boolean; + marginable?: boolean; + shortable?: boolean; + easy_to_borrow?: boolean; + fractionable?: boolean; + attributes?: string[]; +} + /** Narrow slice of the SDK we use for market data. */ export interface AlpacaDataClient { getBarsV2(symbol: string, options: Record): AsyncIterable; newTimeframe(amount: number, unit: string): string; // SDK enum keys are uppercase: MIN="Min", HOUR="Hour", DAY="Day". timeframeUnit: { MIN: string; HOUR: string; DAY: string }; + /** Trading API: asset metadata. Present on the full SDK client. */ + getAsset?(symbol: string): Promise; } export type AlpacaDataClientFactory = () => AlpacaDataClient; @@ -116,4 +134,31 @@ export class AlpacaMarketDataProvider implements MarketDataProvider { async search(): Promise { return []; } + + /** Company/asset metadata from Alpaca's assets endpoint. */ + async getAsset(symbol: string): Promise { + const client = this.factory(); + if (typeof client.getAsset !== 'function') return null; + const canonical = normalizeSymbol(symbol); + try { + const a = await client.getAsset(canonical); + if (!a) return null; + const attrs = Array.isArray(a.attributes) ? a.attributes : []; + return { + symbol: a.symbol ?? canonical, + name: a.name ?? null, + exchange: a.exchange ?? null, + assetClass: a.class ?? null, + status: a.status ?? null, + tradable: a.tradable ?? null, + marginable: a.marginable ?? null, + shortable: a.shortable ?? null, + easyToBorrow: a.easy_to_borrow ?? null, + fractionable: a.fractionable ?? null, + hasOptions: attrs.length ? attrs.some((x) => x.includes('option')) : null, + }; + } catch { + return null; + } + } } diff --git a/src/lib/finance/market-data/finnhub.ts b/src/lib/finance/market-data/finnhub.ts index 3a47824..f4c4591 100644 --- a/src/lib/finance/market-data/finnhub.ts +++ b/src/lib/finance/market-data/finnhub.ts @@ -8,6 +8,7 @@ */ import { + type AssetInfo, type Candle, type MarketDataProvider, type Quote, @@ -70,6 +71,11 @@ export class FinnhubMarketDataProvider implements MarketDataProvider { return this.candleProvider.getCandles(symbol, range); } + /** Asset metadata comes from the delegate (Alpaca) when it supports it. */ + async getAsset(symbol: string): Promise { + return this.candleProvider.getAsset?.(symbol) ?? null; + } + async getQuote(symbol: string): Promise { const canonical = normalizeSymbol(symbol); const res = await this.fetchFn(this.url('/quote', { symbol: canonical })); diff --git a/src/lib/finance/market-data/types.ts b/src/lib/finance/market-data/types.ts index 6758ce8..f7b9d34 100644 --- a/src/lib/finance/market-data/types.ts +++ b/src/lib/finance/market-data/types.ts @@ -46,12 +46,36 @@ export interface SymbolSearchResult { exchange?: string; } +/** + * Company / asset metadata for the ticker page. Sourced from the broker's + * assets endpoint (Alpaca). All fields are nullable — providers fill in only + * what they expose. + */ +export interface AssetInfo { + symbol: string; + name: string | null; + exchange: string | null; + /** e.g. "us_equity", "crypto". */ + assetClass: string | null; + /** e.g. "active", "inactive". */ + status: string | null; + tradable: boolean | null; + marginable: boolean | null; + shortable: boolean | null; + easyToBorrow: boolean | null; + fractionable: boolean | null; + /** Whether the asset has tradable options (from Alpaca asset attributes). */ + hasOptions: boolean | null; +} + export interface MarketDataProvider { /** Stable identifier used in cache keys and logs. */ readonly id: string; getCandles(symbol: string, range: TickerRange): Promise; getQuote(symbol: string): Promise; search(query: string): Promise; + /** Company/asset metadata, when the provider exposes it (Alpaca). */ + getAsset?(symbol: string): Promise; } /** Approximate trailing-day window for each range (daily EOD bars). */ diff --git a/src/lib/finance/performance.test.ts b/src/lib/finance/performance.test.ts new file mode 100644 index 0000000..1595259 --- /dev/null +++ b/src/lib/finance/performance.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { computeChanges, pctChangeOverDays, EMPTY_CHANGES } from './performance'; +import type { Candle } from './market-data/types'; + +const DAY = 86_400; + +/** Build a daily candle series ending "today" from a list of closes. */ +function series(closes: number[], endTime = 1_700_000_000): Candle[] { + const n = closes.length; + return closes.map((close, i) => ({ + time: endTime - (n - 1 - i) * DAY, + open: close, + high: close, + low: close, + close, + volume: 0, + })); +} + +describe('pctChangeOverDays', () => { + it('computes the 1-day change from the prior trading day', () => { + const candles = series([100, 110]); // yesterday 100 → today 110 + expect(pctChangeOverDays(candles, 1)).toBeCloseTo(10, 5); + }); + + it('computes the 5-day change against the bar ~5 days back', () => { + const candles = series([100, 101, 102, 103, 104, 120]); + // last.time - 5d lands on the first bar (close 100): (120-100)/100 = 20% + expect(pctChangeOverDays(candles, 5)).toBeCloseTo(20, 5); + }); + + it('handles a 30-day window, picking the nearest prior bar', () => { + const closes = Array.from({ length: 31 }, (_, i) => 100 + i); // 100..130 + const candles = series(closes); + // 30 days back == first bar (100): (130-100)/100 = 30% + expect(pctChangeOverDays(candles, 30)).toBeCloseTo(30, 5); + }); + + it('falls back to the oldest bar when history is too short', () => { + const candles = series([50, 75]); // only 1 day apart, asked for 30 + expect(pctChangeOverDays(candles, 30)).toBeCloseTo(50, 5); + }); + + it('returns null for insufficient data', () => { + expect(pctChangeOverDays([], 1)).toBeNull(); + expect(pctChangeOverDays(series([100]), 1)).toBeNull(); + }); + + it('returns negative values for losses', () => { + expect(pctChangeOverDays(series([200, 150]), 1)).toBeCloseTo(-25, 5); + }); +}); + +describe('computeChanges', () => { + it('returns all three windows', () => { + const closes = Array.from({ length: 31 }, (_, i) => 100 + i); + const changes = computeChanges(series(closes)); + expect(changes.d1).toBeCloseTo((130 - 129) / 129 * 100, 5); + expect(changes.d5).toBeCloseTo((130 - 125) / 125 * 100, 5); + expect(changes.d30).toBeCloseTo(30, 5); + }); + + it('sorts unordered candles before computing', () => { + const ordered = series([100, 110]); + const shuffled = [ordered[1], ordered[0]]; + expect(computeChanges(shuffled).d1).toBeCloseTo(10, 5); + }); + + it('returns empty changes for too-short input', () => { + expect(computeChanges([])).toEqual(EMPTY_CHANGES); + expect(computeChanges(series([100]))).toEqual(EMPTY_CHANGES); + }); +}); diff --git a/src/lib/finance/performance.ts b/src/lib/finance/performance.ts new file mode 100644 index 0000000..d3c6918 --- /dev/null +++ b/src/lib/finance/performance.ts @@ -0,0 +1,56 @@ +/** + * Finance — trailing % change helpers for the watchlist. + * + * Given a series of daily EOD candles (ascending by time), compute the percent + * gain/loss over the trailing 1, 5, and 30 calendar days. We look back by + * calendar days (not bar count) and pick the latest bar at-or-before the cutoff, + * so weekends/holidays/gaps resolve to the nearest prior trading day. + */ + +import type { Candle } from './market-data/types'; + +export interface WatchlistChanges { + /** Trailing 1-day % change (previous trading day → latest). */ + d1: number | null; + /** Trailing 5-day % change. */ + d5: number | null; + /** Trailing 30-day % change. */ + d30: number | null; +} + +export const EMPTY_CHANGES: WatchlistChanges = { d1: null, d5: null, d30: null }; + +const DAY_SECONDS = 86_400; + +/** Percent change over `days` calendar days, or null if not derivable. */ +export function pctChangeOverDays(candles: Candle[], days: number): number | null { + if (candles.length < 2) return null; + const last = candles[candles.length - 1]; + if (!last?.close) return null; + + const cutoff = last.time - days * DAY_SECONDS; + let base: Candle | undefined; + for (let i = candles.length - 2; i >= 0; i--) { + if (candles[i].time <= cutoff) { + base = candles[i]; + break; + } + } + // Not enough history to reach the full lookback: fall back to the oldest bar + // we have so a partial window still surfaces a (smaller) change. + if (!base) base = candles[0]; + if (!base.close) return null; + + return ((last.close - base.close) / base.close) * 100; +} + +/** Compute trailing 1/5/30-day changes from a daily candle series. */ +export function computeChanges(candles: Candle[]): WatchlistChanges { + if (!candles || candles.length < 2) return EMPTY_CHANGES; + const sorted = [...candles].sort((a, b) => a.time - b.time); + return { + d1: pctChangeOverDays(sorted, 1), + d5: pctChangeOverDays(sorted, 5), + d30: pctChangeOverDays(sorted, 30), + }; +} From c8e6e8167645af8070859dc0fbe752a39ad1fbe8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 17 Jun 2026 20:21:13 +0000 Subject: [PATCH 2/2] fix(news): drop unsupported temperature from summarize (gpt-5.x 400) The /news article summarize call sent temperature: 0.3, which gpt-5.x reasoning models reject ('Only the default (1) value is supported') -> 400. Remove it, same as the finance report fix. Update the test to assert no temperature is sent. Co-Authored-By: Claude Opus 4.8 --- src/app/api/news/summarize/route.test.ts | 3 ++- src/app/api/news/summarize/route.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/api/news/summarize/route.test.ts b/src/app/api/news/summarize/route.test.ts index a8d1e66..06e66d1 100644 --- a/src/app/api/news/summarize/route.test.ts +++ b/src/app/api/news/summarize/route.test.ts @@ -473,12 +473,13 @@ describe('News Summarize API Route', () => { response_format: { type: 'json_object' }, max_completion_tokens: 1500, reasoning_effort: 'high', - temperature: 0.3, }), expect.objectContaining({ timeout: 60000, }) ); + // gpt-5.x reasoning models reject a custom temperature; we must not send one. + expect(mockOpenAICreate.mock.calls[0][0]).not.toHaveProperty('temperature'); }); it('should include article content in the prompt', async () => { diff --git a/src/app/api/news/summarize/route.ts b/src/app/api/news/summarize/route.ts index b8f780a..8ba5906 100644 --- a/src/app/api/news/summarize/route.ts +++ b/src/app/api/news/summarize/route.ts @@ -195,7 +195,8 @@ ${truncatedContent}`; response_format: { type: 'json_object' }, max_completion_tokens: 1500, reasoning_effort: 'high', - temperature: 0.3, + // gpt-5.x reasoning models only support the default temperature (1); + // passing a custom value returns a 400. }, { timeout: 60000, // 60 second timeout