Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions src/app/api/finance/candles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -32,18 +34,34 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
}

try {
const provider = getMarketDataProvider();
const candles = await readThrough<Candle[]>(
const profileId = await getActiveProfileId();
const provider = await getMarketDataProviderForProfile(profileId);

const fetchFrom = (p: MarketDataProvider) =>
readThrough<Candle[]>(
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 });
}
}
}
36 changes: 28 additions & 8 deletions src/app/api/finance/quote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,13 +30,31 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
}

try {
const provider = getMarketDataProvider();
const quote = await readThrough<Quote | null>(
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<Quote | null>(
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<Quote | null>(
symbol,
`quote:${fallback.id}`,
QUOTE_TTL_SECONDS,
() => fallback.getQuote(symbol),
);
}

if (!quote) {
return NextResponse.json({ error: 'no data for symbol' }, { status: 404 });
Expand Down
2 changes: 1 addition & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
25 changes: 25 additions & 0 deletions src/lib/finance/brokers/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrokerCredentials | null> {
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<BrokerCredentials>(data.encrypted_credentials);
} catch {
return null;
}
}

export async function listConnections(profileId: string): Promise<ConnectionView[]> {
const { data } = await getServerClient()
.from(CONNECTIONS_TABLE)
Expand Down
30 changes: 30 additions & 0 deletions src/lib/finance/market-data/for-profile.ts
Original file line number Diff line number Diff line change
@@ -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<MarketDataProvider> {
if (profileId) {
const alpaca = await getActiveBrokerCreds(profileId, 'alpaca');
if (alpaca) {
return new AlpacaMarketDataProvider({
apiKey: alpaca.apiKey,
apiSecret: alpaca.apiSecret,
});
}
}
return getMarketDataProvider();
}
19 changes: 13 additions & 6 deletions src/lib/finance/market-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 })
Expand All @@ -34,12 +35,18 @@ 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;
}

export * from './types';
export { StooqMarketDataProvider } from './stooq';
export { YahooMarketDataProvider } from './yahoo';
export { FinnhubMarketDataProvider } from './finnhub';
export { AlpacaMarketDataProvider } from './alpaca';
114 changes: 114 additions & 0 deletions src/lib/finance/market-data/yahoo.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> }>;

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<TickerRange, { range: string; interval: string }> = {
'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<FetchFn>);
}

async getCandles(symbol: string, range: TickerRange): Promise<Candle[]> {
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<Quote | null> {
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<SymbolSearchResult[]> {
const q = query.trim();
if (!q) return [];
const res = await this.fetchFn(`${SEARCH_BASE}?q=${encodeURIComponent(q)}&quotesCount=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 }));
}
}
Loading