270 lines
7.4 KiB
TypeScript
270 lines
7.4 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;
|
|
|
|
type CacheEntry<T> = {
|
|
expiresAt: number;
|
|
value: T;
|
|
};
|
|
|
|
const quoteCache = new Map<string, CacheEntry<number>>();
|
|
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
|
|
|
|
function buildYahooChartUrl(ticker: string, params: string) {
|
|
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
|
}
|
|
|
|
function fallbackQuote(ticker: string) {
|
|
const normalized = ticker.trim().toUpperCase();
|
|
let hash = 0;
|
|
|
|
for (const char of normalized) {
|
|
hash = (hash * 31 + char.charCodeAt(0)) % 100000;
|
|
}
|
|
|
|
return 40 + (hash % 360) + ((hash % 100) / 100);
|
|
}
|
|
|
|
export async function getQuote(ticker: string): Promise<number> {
|
|
const normalizedTicker = ticker.trim().toUpperCase();
|
|
const cached = quoteCache.get(normalizedTicker);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.value;
|
|
}
|
|
|
|
let quote = fallbackQuote(normalizedTicker);
|
|
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) {
|
|
quoteCache.set(normalizedTicker, {
|
|
value: quote,
|
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
|
});
|
|
return quote;
|
|
}
|
|
|
|
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)) {
|
|
quoteCache.set(normalizedTicker, {
|
|
value: quote,
|
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
|
});
|
|
return quote;
|
|
}
|
|
quote = price;
|
|
} catch {
|
|
// fall through to cached fallback
|
|
}
|
|
|
|
quoteCache.set(normalizedTicker, {
|
|
value: quote,
|
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
|
});
|
|
|
|
return quote;
|
|
}
|
|
|
|
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
|
const normalizedTicker = ticker.trim().toUpperCase();
|
|
|
|
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) {
|
|
return null;
|
|
}
|
|
|
|
const payload = await response.json() as {
|
|
chart?: {
|
|
result?: Array<{ meta?: { regularMarketPrice?: number } }>;
|
|
};
|
|
};
|
|
|
|
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
|
return typeof price === 'number' && Number.isFinite(price) ? price : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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<Array<{ date: string; close: number }>> {
|
|
const normalizedTicker = ticker.trim().toUpperCase();
|
|
const cached = priceHistoryCache.get(normalizedTicker);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.value;
|
|
}
|
|
|
|
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) {
|
|
throw new Error('Quote history unavailable');
|
|
}
|
|
|
|
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) {
|
|
priceHistoryCache.set(normalizedTicker, {
|
|
value: points,
|
|
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
|
});
|
|
return points;
|
|
}
|
|
} catch {
|
|
// fall through to deterministic synthetic history
|
|
}
|
|
|
|
const now = Date.now();
|
|
const base = fallbackQuote(normalizedTicker);
|
|
|
|
const totalWeeks = 20 * 52;
|
|
|
|
const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => {
|
|
const step = (totalWeeks - 1) - index;
|
|
const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
const wave = Math.sin(index / 8) * 0.06;
|
|
const trend = (index - totalWeeks / 2) * 0.0009;
|
|
const close = Math.max(base * (1 + wave + trend), 1);
|
|
|
|
return {
|
|
date,
|
|
close: Number(close.toFixed(2))
|
|
};
|
|
});
|
|
|
|
priceHistoryCache.set(normalizedTicker, {
|
|
value: syntheticHistory,
|
|
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
|
});
|
|
|
|
return syntheticHistory;
|
|
}
|
|
|
|
export const __pricesInternals = {
|
|
PRICE_HISTORY_CACHE_TTL_MS,
|
|
QUOTE_CACHE_TTL_MS,
|
|
resetCaches() {
|
|
quoteCache.clear();
|
|
priceHistoryCache.clear();
|
|
}
|
|
};
|