From b71e4c6673b9bcda696eed82accbeef8ea36431b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 17 Jun 2026 17:10:03 +0000 Subject: [PATCH] fix(finance): working chart data (connected Alpaca + Yahoo fallback) + button padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Charts were blank: the keyless Stooq source now returns an anti-bot challenge instead of CSV, and no app-level market-data keys are set in prod. - Prefer the user's CONNECTED Alpaca keys for candles/quotes (getActiveBrokerCreds + getMarketDataProviderForProfile), matching the keys they entered. - Add a keyless Yahoo provider (verified working) as the default + fallback, so the chart renders even if Alpaca data access fails (candles/quote routes fall back). - globals.css: the Tailwind v4 `@utility btn` lost its padding in the v3->v4 migration — restore `px-4 py-2` (+ gap-2) so all buttons aren't cramped. Co-Authored-By: Claude Opus 4.8 --- src/app/api/finance/candles/route.ts | 38 +++++-- src/app/api/finance/quote/route.ts | 36 +++++-- src/app/globals.css | 2 +- src/lib/finance/brokers/service.ts | 25 +++++ src/lib/finance/market-data/for-profile.ts | 30 ++++++ src/lib/finance/market-data/index.ts | 19 ++-- src/lib/finance/market-data/yahoo.ts | 114 +++++++++++++++++++++ 7 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 src/lib/finance/market-data/for-profile.ts create mode 100644 src/lib/finance/market-data/yahoo.ts diff --git a/src/app/api/finance/candles/route.ts b/src/app/api/finance/candles/route.ts index 601bcda..e0210ef 100644 --- a/src/app/api/finance/candles/route.ts +++ b/src/app/api/finance/candles/route.ts @@ -6,10 +6,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireActiveSubscription } from '@/lib/subscription/guard'; -import { getMarketDataProvider, isTickerRange } from '@/lib/finance/market-data'; +import { getActiveProfileId } from '@/lib/profiles/profile-utils'; +import { isTickerRange, getFallbackMarketDataProvider } from '@/lib/finance/market-data'; +import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile'; import { normalizeSymbol } from '@/lib/finance/market-data/stooq'; import { readThrough, CANDLES_TTL_SECONDS } from '@/lib/finance/market-data/cache'; -import type { Candle } from '@/lib/finance/market-data'; +import type { Candle, MarketDataProvider } from '@/lib/finance/market-data'; export const dynamic = 'force-dynamic'; @@ -32,18 +34,34 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: 'invalid symbol' }, { status: 400 }); } - try { - const provider = getMarketDataProvider(); - const candles = await readThrough( + const profileId = await getActiveProfileId(); + const provider = await getMarketDataProviderForProfile(profileId); + + const fetchFrom = (p: MarketDataProvider) => + readThrough( symbol, - `candles:${provider.id}:${rangeParam}`, + `candles:${p.id}:${rangeParam}`, CANDLES_TTL_SECONDS, - () => provider.getCandles(symbol, rangeParam), + () => p.getCandles(symbol, rangeParam), ); - return NextResponse.json({ symbol, range: rangeParam, candles }); + try { + const candles = await fetchFrom(provider); + return NextResponse.json({ symbol, range: rangeParam, candles, source: provider.id }); } catch (error) { - console.error('[finance/candles] error:', error); - return NextResponse.json({ error: 'failed to load candles' }, { status: 502 }); + // The user's broker (e.g. Alpaca) failed — fall back to the keyless source so + // the chart still renders instead of going blank. + console.error(`[finance/candles] ${provider.id} failed, falling back:`, error); + const fallback = getFallbackMarketDataProvider(); + if (fallback.id === provider.id) { + return NextResponse.json({ error: 'failed to load candles' }, { status: 502 }); + } + try { + const candles = await fetchFrom(fallback); + return NextResponse.json({ symbol, range: rangeParam, candles, source: fallback.id }); + } catch (fallbackError) { + console.error('[finance/candles] fallback failed:', fallbackError); + return NextResponse.json({ error: 'failed to load candles' }, { status: 502 }); + } } } diff --git a/src/app/api/finance/quote/route.ts b/src/app/api/finance/quote/route.ts index fb538e4..ce84c77 100644 --- a/src/app/api/finance/quote/route.ts +++ b/src/app/api/finance/quote/route.ts @@ -6,7 +6,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireActiveSubscription } from '@/lib/subscription/guard'; -import { getMarketDataProvider } from '@/lib/finance/market-data'; +import { getActiveProfileId } from '@/lib/profiles/profile-utils'; +import { getMarketDataProviderForProfile } from '@/lib/finance/market-data/for-profile'; +import { getFallbackMarketDataProvider } from '@/lib/finance/market-data'; import { normalizeSymbol } from '@/lib/finance/market-data/stooq'; import { readThrough, QUOTE_TTL_SECONDS } from '@/lib/finance/market-data/cache'; import type { Quote } from '@/lib/finance/market-data'; @@ -28,13 +30,31 @@ export async function GET(request: NextRequest): Promise { } try { - const provider = getMarketDataProvider(); - const quote = await readThrough( - symbol, - `quote:${provider.id}`, - QUOTE_TTL_SECONDS, - () => provider.getQuote(symbol), - ); + const profileId = await getActiveProfileId(); + const provider = await getMarketDataProviderForProfile(profileId); + + let quote: Quote | null; + try { + quote = await readThrough( + symbol, + `quote:${provider.id}`, + QUOTE_TTL_SECONDS, + () => provider.getQuote(symbol), + ); + } catch (err) { + // Broker (e.g. Alpaca) failed — fall back to the keyless source. + console.error(`[finance/quote] ${provider.id} failed, falling back:`, err); + const fallback = getFallbackMarketDataProvider(); + quote = + fallback.id === provider.id + ? null + : await readThrough( + symbol, + `quote:${fallback.id}`, + QUOTE_TTL_SECONDS, + () => fallback.getQuote(symbol), + ); + } if (!quote) { return NextResponse.json({ error: 'no data for symbol' }, { status: 404 }); diff --git a/src/app/globals.css b/src/app/globals.css index 6d3cac6..34de20e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -141,7 +141,7 @@ @utility btn { /* Button variants */ - @apply inline-flex items-center justify-center rounded-lg font-medium transition-colors; + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 font-medium transition-colors; @apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-primary; @apply disabled:pointer-events-none disabled:opacity-50; } diff --git a/src/lib/finance/brokers/service.ts b/src/lib/finance/brokers/service.ts index 52288ec..bd93b05 100644 --- a/src/lib/finance/brokers/service.ts +++ b/src/lib/finance/brokers/service.ts @@ -31,6 +31,31 @@ export interface HoldingView { asOf: string; } +/** + * Decrypt and return the active broker credentials for a profile+provider, or + * null. Used so a connected user's own market-data candles/quotes come from + * their broker (e.g. Alpaca) rather than a shared/fallback source. + * SERVER-ONLY — never expose the result to the client. + */ +export async function getActiveBrokerCreds( + profileId: string, + provider: string, +): Promise { + const { data } = await getServerClient() + .from(CONNECTIONS_TABLE) + .select('encrypted_credentials, status') + .eq('profile_id', profileId) + .eq('provider', provider) + .maybeSingle(); + + if (!data || data.status !== 'active') return null; + try { + return decryptJson(data.encrypted_credentials); + } catch { + return null; + } +} + export async function listConnections(profileId: string): Promise { const { data } = await getServerClient() .from(CONNECTIONS_TABLE) diff --git a/src/lib/finance/market-data/for-profile.ts b/src/lib/finance/market-data/for-profile.ts new file mode 100644 index 0000000..c2a6a3d --- /dev/null +++ b/src/lib/finance/market-data/for-profile.ts @@ -0,0 +1,30 @@ +/** + * Finance — per-profile market-data provider resolution. + * + * If the profile has connected a broker (Alpaca), use THEIR credentials for + * candles/quotes so the data comes from the account the user actually linked + * (matching the keys they entered). Otherwise fall back to the app-level default + * provider (Finnhub/Stooq/etc). + * + * SERVER-ONLY (decrypts broker credentials). + */ + +import { type MarketDataProvider } from './types'; +import { getMarketDataProvider } from './index'; +import { AlpacaMarketDataProvider } from './alpaca'; +import { getActiveBrokerCreds } from '@/lib/finance/brokers/service'; + +export async function getMarketDataProviderForProfile( + profileId: string | null, +): Promise { + if (profileId) { + const alpaca = await getActiveBrokerCreds(profileId, 'alpaca'); + if (alpaca) { + return new AlpacaMarketDataProvider({ + apiKey: alpaca.apiKey, + apiSecret: alpaca.apiSecret, + }); + } + } + return getMarketDataProvider(); +} diff --git a/src/lib/finance/market-data/index.ts b/src/lib/finance/market-data/index.ts index de9f4a6..ff5ed23 100644 --- a/src/lib/finance/market-data/index.ts +++ b/src/lib/finance/market-data/index.ts @@ -6,17 +6,18 @@ */ import { type MarketDataProvider } from './types'; -import { StooqMarketDataProvider } from './stooq'; +import { YahooMarketDataProvider } from './yahoo'; import { FinnhubMarketDataProvider } from './finnhub'; import { AlpacaMarketDataProvider } from './alpaca'; let provider: MarketDataProvider | null = null; /** - * Compose the active provider from whatever credentials are configured: - * - candles: Alpaca (`ALPACA_API_KEY`/`ALPACA_API_SECRET`, real bars) else Stooq EOD - * - quotes + symbol search: Finnhub (`FINNHUB_API_KEY`, real-time) when present, - * wrapping the candle source; otherwise the candle provider serves quotes too. + * Compose the app-level default provider from whatever credentials are set: + * - candles: Alpaca (`ALPACA_API_KEY`/`ALPACA_API_SECRET`) else keyless Yahoo + * - quotes + symbol search: Finnhub (`FINNHUB_API_KEY`) when present, wrapping + * the candle source; otherwise the candle provider serves quotes/search too. + * (Per-profile resolution prefers the user's CONNECTED broker — see for-profile.ts.) */ export function getMarketDataProvider(): MarketDataProvider { if (!provider) { @@ -25,7 +26,7 @@ export function getMarketDataProvider(): MarketDataProvider { const candleProvider: MarketDataProvider = ALPACA_API_KEY && ALPACA_API_SECRET ? new AlpacaMarketDataProvider({ apiKey: ALPACA_API_KEY, apiSecret: ALPACA_API_SECRET }) - : new StooqMarketDataProvider(); + : new YahooMarketDataProvider(); provider = FINNHUB_API_KEY ? new FinnhubMarketDataProvider({ apiKey: FINNHUB_API_KEY, candleProvider }) @@ -34,6 +35,11 @@ export function getMarketDataProvider(): MarketDataProvider { return provider; } +/** Keyless provider that always works without credentials (Yahoo). */ +export function getFallbackMarketDataProvider(): MarketDataProvider { + return new YahooMarketDataProvider(); +} + /** Test/seam hook to reset the cached singleton. */ export function resetMarketDataProvider(): void { provider = null; @@ -41,5 +47,6 @@ export function resetMarketDataProvider(): void { export * from './types'; export { StooqMarketDataProvider } from './stooq'; +export { YahooMarketDataProvider } from './yahoo'; export { FinnhubMarketDataProvider } from './finnhub'; export { AlpacaMarketDataProvider } from './alpaca'; diff --git a/src/lib/finance/market-data/yahoo.ts b/src/lib/finance/market-data/yahoo.ts new file mode 100644 index 0000000..46e76f4 --- /dev/null +++ b/src/lib/finance/market-data/yahoo.ts @@ -0,0 +1,114 @@ +/** + * Finance — Yahoo Finance market-data adapter (keyless). + * + * Replaces the old Stooq fallback, which now serves an anti-bot JS challenge + * instead of CSV. Yahoo's chart endpoint returns OHLCV JSON without a key and + * supports intraday + daily, so it's a solid keyless default and last-resort + * fallback. (Display-to-authenticated-user only; respect Yahoo ToS.) + */ + +import { + type Candle, + type MarketDataProvider, + type Quote, + type SymbolSearchResult, + type TickerRange, +} from './types'; +import { normalizeSymbol, quoteFromCandles } from './stooq'; + +type FetchFn = (url: string) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +interface YahooProviderOptions { + fetchFn?: FetchFn; +} + +const CHART_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; +const SEARCH_BASE = 'https://query1.finance.yahoo.com/v1/finance/search'; + +/** Yahoo range + interval per chart range. */ +const RANGE_PARAMS: Record = { + '1D': { range: '1d', interval: '5m' }, + '5D': { range: '5d', interval: '15m' }, + '1M': { range: '1mo', interval: '1d' }, + '6M': { range: '6mo', interval: '1d' }, + '1Y': { range: '1y', interval: '1d' }, + '5Y': { range: '5y', interval: '1wk' }, +}; + +interface YahooChartResult { + chart?: { + result?: Array<{ + timestamp?: number[]; + indicators?: { quote?: Array<{ open?: (number | null)[]; high?: (number | null)[]; low?: (number | null)[]; close?: (number | null)[]; volume?: (number | null)[] }> }; + }>; + error?: unknown; + }; +} + +interface YahooSearchResult { + quotes?: Array<{ symbol?: string; shortname?: string; longname?: string; quoteType?: string }>; +} + +function num(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +export class YahooMarketDataProvider implements MarketDataProvider { + readonly id = 'yahoo'; + private readonly fetchFn: FetchFn; + + constructor(options: YahooProviderOptions = {}) { + // Yahoo rejects requests without a browser-like UA. + this.fetchFn = + options.fetchFn ?? + ((url) => fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }) as ReturnType); + } + + async getCandles(symbol: string, range: TickerRange): Promise { + const canonical = normalizeSymbol(symbol); + const { range: r, interval } = RANGE_PARAMS[range]; + const url = `${CHART_BASE}/${encodeURIComponent(canonical)}?range=${r}&interval=${interval}`; + const res = await this.fetchFn(url); + if (!res.ok) throw new Error(`Yahoo chart failed: ${res.status}`); + + const data = (await res.json()) as YahooChartResult; + const result = data.chart?.result?.[0]; + const ts = result?.timestamp; + const q = result?.indicators?.quote?.[0]; + if (!ts || !q) return []; + + const candles: Candle[] = []; + for (let i = 0; i < ts.length; i++) { + const time = ts[i]; + const o = num(q.open?.[i]); + const h = num(q.high?.[i]); + const l = num(q.low?.[i]); + const c = num(q.close?.[i]); + if (!Number.isFinite(time) || o === null || h === null || l === null || c === null || c <= 0) { + continue; + } + candles.push({ time, open: o, high: h, low: l, close: c, volume: num(q.volume?.[i]) ?? 0 }); + } + candles.sort((a, b) => a.time - b.time); + return candles; + } + + async getQuote(symbol: string): Promise { + const canonical = normalizeSymbol(symbol); + // Derive from recent daily candles (keeps a single code path). + const candles = await this.getCandles(canonical, '5D'); + return quoteFromCandles(canonical, candles); + } + + async search(query: string): Promise { + const q = query.trim(); + if (!q) return []; + const res = await this.fetchFn(`${SEARCH_BASE}?q=${encodeURIComponent(q)}"esCount=10&newsCount=0`); + if (!res.ok) return []; + const data = (await res.json()) as YahooSearchResult; + return (data.quotes ?? []) + .filter((r) => typeof r.symbol === 'string' && r.quoteType === 'EQUITY' && !r.symbol.includes('.')) + .slice(0, 12) + .map((r) => ({ symbol: String(r.symbol).toUpperCase(), name: r.shortname ?? r.longname })); + } +}