Files
Neon-Desk/lib/server/prices.ts

269 lines
7.6 KiB
TypeScript

const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
const QUOTE_CACHE_TTL_MS = 1000 * 60;
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
const FAILURE_CACHE_TTL_MS = 1000 * 30;
type QuoteResult = {
value: number | null;
stale: boolean;
};
type PriceHistoryResult = {
value: Array<{ date: string; close: number }> | null;
stale: boolean;
};
type QuoteCacheEntry = {
expiresAt: number;
value: number | null;
};
type PriceHistoryCacheEntry = {
expiresAt: number;
value: Array<{ date: string; close: number }> | null;
};
const quoteCache = new Map<string, QuoteCacheEntry>();
const priceHistoryCache = new Map<string, PriceHistoryCacheEntry>();
function buildYahooChartUrl(ticker: string, params: string) {
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
}
export async function getQuote(ticker: string): Promise<QuoteResult> {
const normalizedTicker = ticker.trim().toUpperCase();
const cached = quoteCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) {
return { value: cached.value, stale: false };
}
const staleEntry = cached;
try {
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)'
},
cache: 'no-store'
});
if (!response.ok) {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
quoteCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
const payload = await response.json() as {
chart?: {
result?: Array<{ meta?: { regularMarketPrice?: number } }>;
};
};
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
if (typeof price !== 'number' || !Number.isFinite(price)) {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
quoteCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
quoteCache.set(normalizedTicker, {
value: price,
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
});
return { value: price, stale: false };
} catch {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
quoteCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
}
async function getQuoteOrNull(ticker: string): Promise<number | null> {
const result = await getQuote(ticker);
return result.value;
}
export async function getHistoricalClosingPrices(ticker: string, dates: string[]) {
const normalizedTicker = ticker.trim().toUpperCase();
const normalizedDates = dates
.map((value) => {
const parsed = Date.parse(value);
return Number.isFinite(parsed)
? { raw: value, iso: new Date(parsed).toISOString().slice(0, 10), epoch: parsed }
: null;
})
.filter((entry): entry is { raw: string; iso: string; epoch: number } => entry !== null);
if (normalizedDates.length === 0) {
return {} as Record<string, number | null>;
}
try {
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=20y'), {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)'
},
cache: 'no-store'
});
if (!response.ok) {
return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null]));
}
const payload = await response.json() as {
chart?: {
result?: Array<{
timestamp?: number[];
indicators?: {
quote?: Array<{
close?: Array<number | null>;
}>;
};
}>;
};
};
const result = payload.chart?.result?.[0];
const timestamps = result?.timestamp ?? [];
const closes = result?.indicators?.quote?.[0]?.close ?? [];
const points = timestamps
.map((timestamp, index) => {
const close = closes[index];
if (typeof close !== 'number' || !Number.isFinite(close)) {
return null;
}
return {
epoch: timestamp * 1000,
close
};
})
.filter((entry): entry is { epoch: number; close: number } => entry !== null);
return Object.fromEntries(normalizedDates.map((entry) => {
const point = [...points]
.reverse()
.find((candidate) => candidate.epoch <= entry.epoch) ?? null;
return [entry.raw, point?.close ?? null];
}));
} catch {
return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null]));
}
}
export async function getPriceHistory(ticker: string): Promise<PriceHistoryResult> {
const normalizedTicker = ticker.trim().toUpperCase();
const cached = priceHistoryCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) {
return { value: cached.value, stale: false };
}
const staleEntry = cached;
try {
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)'
},
cache: 'no-store'
});
if (!response.ok) {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
priceHistoryCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
const payload = await response.json() as {
chart?: {
result?: Array<{
timestamp?: number[];
indicators?: {
quote?: Array<{
close?: Array<number | null>;
}>;
};
}>;
};
};
const result = payload.chart?.result?.[0];
const timestamps = result?.timestamp ?? [];
const closes = result?.indicators?.quote?.[0]?.close ?? [];
const points = timestamps
.map((timestamp, index) => {
const close = closes[index];
if (typeof close !== 'number' || !Number.isFinite(close)) {
return null;
}
return {
date: new Date(timestamp * 1000).toISOString(),
close
};
})
.filter((entry): entry is { date: string; close: number } => entry !== null);
if (points.length === 0) {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
priceHistoryCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
priceHistoryCache.set(normalizedTicker, {
value: points,
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
});
return { value: points, stale: false };
} catch {
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
return { value: staleEntry.value, stale: true };
}
priceHistoryCache.set(normalizedTicker, {
value: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
});
return { value: null, stale: false };
}
}
export const __pricesInternals = {
FAILURE_CACHE_TTL_MS,
PRICE_HISTORY_CACHE_TTL_MS,
QUOTE_CACHE_TTL_MS,
resetCaches() {
quoteCache.clear();
priceHistoryCache.clear();
}
};