Skip to content

Commit 59b22bd

Browse files
authored
Merge pull request #127 from YuqiGuo105/replace-stooq-api-with-yahoo-finance-api
Replace Stooq market-data with Yahoo Finance Chart API
2 parents a850a9b + 611f48f commit 59b22bd

2 files changed

Lines changed: 81 additions & 94 deletions

File tree

pages/api/market-data.js

Lines changed: 72 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// pages/api/market-data.js
22

3-
// Free / no-key market data source (CSV):
4-
// https://stooq.com/q/l/?s=gs.us,^spx&f=sd2t2ohlcv&h&e=csv
3+
// Free / no-key market data source (JSON):
4+
// https://query1.finance.yahoo.com/v8/finance/chart/GS
55
// Note: data may be delayed / not real-time for all symbols.
66

77
const SYMBOLS = [
8-
{ label: "GS", stooq: "gs.us", currency: "USD", shortName: "Goldman Sachs Group, Inc." },
9-
{ label: "SPX", stooq: "^spx", currency: "USD", shortName: "S&P 500 Index" },
10-
{ label: "UKX", stooq: "^ftse", currency: "GBP", shortName: "FTSE 100 Index" },
11-
{ label: "NDX", stooq: "^ndx", currency: "USD", shortName: "NASDAQ 100 Index" },
12-
{ label: "NKY", stooq: "^n225", currency: "JPY", shortName: "Nikkei 225" },
8+
{ label: "GS", yahoo: "GS", currency: "USD", shortName: "Goldman Sachs Group, Inc." },
9+
{ label: "SPX", yahoo: "^GSPC", currency: "USD", shortName: "S&P 500 Index" },
10+
{ label: "UKX", yahoo: "^FTSE", currency: "GBP", shortName: "FTSE 100 Index" },
11+
{ label: "NDX", yahoo: "^NDX", currency: "USD", shortName: "NASDAQ 100 Index" },
12+
{ label: "NKY", yahoo: "^N225", currency: "JPY", shortName: "Nikkei 225" },
1313
];
1414

1515
// Keep your baseline snapshot and simulated fallback behavior.
@@ -32,51 +32,36 @@ const parseNumber = (v) => {
3232
return Number.isFinite(n) ? n : null;
3333
};
3434

35-
const parseTimestampMs = (dateStr, timeStr) => {
36-
if (!dateStr) return null;
37-
const t = (timeStr && timeStr.trim()) ? timeStr.trim() : "00:00:00";
38-
const d = new Date(`${dateStr}T${t}`);
39-
const ms = d.getTime();
40-
return Number.isFinite(ms) ? ms : null;
41-
};
42-
43-
const parseStooqCsv = (csvText) => {
44-
const text = (csvText ?? "").trim();
45-
if (!text) return [];
46-
47-
const lines = text.split(/\r?\n/).filter(Boolean);
48-
if (lines.length < 2) return [];
49-
50-
// Expected header:
51-
// Symbol,Date,Time,Open,High,Low,Close,Volume
52-
const header = lines[0].split(",").map((s) => s.trim());
53-
const idx = (name) => header.findIndex((h) => h.toLowerCase() === name.toLowerCase());
54-
55-
const iSymbol = idx("Symbol");
56-
const iDate = idx("Date");
57-
const iTime = idx("Time");
58-
const iOpen = idx("Open");
59-
const iClose = idx("Close");
60-
61-
if (iSymbol < 0 || iDate < 0 || iOpen < 0 || iClose < 0) return [];
62-
63-
const rows = [];
64-
for (let i = 1; i < lines.length; i += 1) {
65-
const cols = lines[i].split(",").map((s) => s.trim());
66-
const symbol = cols[iSymbol]?.toLowerCase();
67-
if (!symbol) continue;
35+
const parseYahooChart = (payload) => {
36+
const result = payload?.chart?.result?.[0];
37+
if (!result) return null;
38+
39+
const timestamps = Array.isArray(result.timestamp) ? result.timestamp : [];
40+
const quote = result.indicators?.quote?.[0] ?? {};
41+
const opens = Array.isArray(quote.open) ? quote.open : [];
42+
const closes = Array.isArray(quote.close) ? quote.close : [];
43+
44+
let lastIdx = -1;
45+
for (let i = closes.length - 1; i >= 0; i -= 1) {
46+
if (parseNumber(closes[i]) !== null) {
47+
lastIdx = i;
48+
break;
49+
}
50+
}
51+
if (lastIdx < 0) return null;
6852

69-
const open = parseNumber(cols[iOpen]);
70-
const close = parseNumber(cols[iClose]);
53+
const close = parseNumber(closes[lastIdx]);
54+
const open = parseNumber(opens[lastIdx]);
55+
const tsSec = timestamps[lastIdx];
7156

72-
const dateStr = cols[iDate] || null;
73-
const timeStr = iTime >= 0 ? (cols[iTime] || null) : null;
74-
const ts = parseTimestampMs(dateStr, timeStr);
57+
const timestamp = Number.isFinite(tsSec) ? tsSec * 1000 : Date.now();
7558

76-
rows.push({ symbol, open, close, ts });
77-
}
78-
79-
return rows;
59+
return {
60+
open,
61+
close,
62+
timestamp,
63+
shortName: result.meta?.shortName ?? null,
64+
};
8065
};
8166

8267
const simulateFromBaseline = (error) => {
@@ -129,13 +114,13 @@ const buildFallbackItem = (label, now, index) => {
129114
};
130115
};
131116

132-
const fetchTextWithTimeout = async (url, ms) => {
117+
const fetchWithTimeout = async (url, ms) => {
133118
const controller = new AbortController();
134119
const timeout = setTimeout(() => controller.abort(), ms);
135120
try {
136121
const resp = await fetch(url, {
137122
method: "GET",
138-
headers: { Accept: "text/csv,*/*" },
123+
headers: { Accept: "application/json,*/*" },
139124
signal: controller.signal,
140125
});
141126
return resp;
@@ -144,6 +129,18 @@ const fetchTextWithTimeout = async (url, ms) => {
144129
}
145130
};
146131

132+
const fetchYahooQuote = async (symbol) => {
133+
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=5d`;
134+
const resp = await fetchWithTimeout(url, FETCH_TIMEOUT_MS);
135+
if (!resp.ok) throw new Error(`Yahoo request failed for ${symbol} with ${resp.status}`);
136+
137+
const json = await resp.json();
138+
const parsed = parseYahooChart(json);
139+
if (!parsed) throw new Error(`Yahoo returned no usable chart data for ${symbol}`);
140+
141+
return parsed;
142+
};
143+
147144
export default async function handler(req, res) {
148145
const now = Date.now();
149146

@@ -155,48 +152,39 @@ export default async function handler(req, res) {
155152
}
156153

157154
try {
158-
const stooqSymbols = SYMBOLS.map((s) => s.stooq).join(",");
159-
const url = `https://stooq.com/q/l/?s=${encodeURIComponent(
160-
stooqSymbols
161-
)}&f=sd2t2ohlcv&h&e=csv`;
162-
163-
const resp = await fetchTextWithTimeout(url, FETCH_TIMEOUT_MS);
164-
if (!resp.ok) throw new Error(`Stooq request failed with ${resp.status}`);
165-
166-
const csv = await resp.text();
167-
const rows = parseStooqCsv(csv);
168-
169-
const bySymbol = new Map();
170-
for (const r of rows) bySymbol.set(r.symbol, r);
171-
172155
const missing = [];
173-
const data = SYMBOLS.map((cfg, index) => {
174-
const r = bySymbol.get(cfg.stooq.toLowerCase());
175-
if (!r || r.open === null || r.close === null) {
156+
const data = await Promise.all(SYMBOLS.map(async (cfg, index) => {
157+
try {
158+
const quote = await fetchYahooQuote(cfg.yahoo);
159+
if (quote.open === null || quote.close === null) {
160+
missing.push(cfg.label);
161+
return buildFallbackItem(cfg.label, now, index);
162+
}
163+
164+
const change = quote.close - quote.open;
165+
const changePercent = quote.open ? (change / quote.open) * 100 : null;
166+
167+
return {
168+
label: cfg.label,
169+
price: quote.close,
170+
currency: cfg.currency,
171+
change: Number.isFinite(change) ? Number(change.toFixed(2)) : null,
172+
changePercent: Number.isFinite(changePercent) ? Number(changePercent.toFixed(2)) : null,
173+
timestamp: quote.timestamp ?? now,
174+
shortName: quote.shortName ?? cfg.shortName ?? cfg.label,
175+
};
176+
} catch {
176177
missing.push(cfg.label);
177178
return buildFallbackItem(cfg.label, now, index);
178179
}
180+
})).then((rows) => rows.filter(Boolean));
179181

180-
const change = r.close - r.open;
181-
const changePercent = r.open ? (change / r.open) * 100 : null;
182-
183-
return {
184-
label: cfg.label,
185-
price: r.close,
186-
currency: cfg.currency,
187-
change: Number.isFinite(change) ? Number(change.toFixed(2)) : null,
188-
changePercent: Number.isFinite(changePercent) ? Number(changePercent.toFixed(2)) : null,
189-
timestamp: r.ts ?? now,
190-
shortName: cfg.shortName ?? cfg.label,
191-
};
192-
}).filter(Boolean);
193-
194-
if (!data.length) throw new Error("Stooq returned no usable data");
182+
if (!data.length) throw new Error("Yahoo returned no usable data");
195183

196184
const payload = {
197185
data,
198186
meta: {
199-
source: "stooq",
187+
source: "yahoo-finance",
200188
updatedAt: now,
201189
partial: missing.length ? missing : null,
202190
},

src/components/DashboardPanels.js

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -624,16 +624,8 @@ const DashboardPanels = () => {
624624
return { marketBadgeText: "loading…", marketBadgeClassName: "badge badge-warning" };
625625
}
626626

627-
if (marketMeta?.source && marketMeta.source !== "stooq") {
628-
const text =
629-
marketMeta.source === "simulated"
630-
? "live feed unavailable · showing simulated snapshot"
631-
: `cached snapshot · source: ${marketMeta.source}`;
632-
return { marketBadgeText: text, marketBadgeClassName: "badge badge-warning" };
633-
}
634-
635-
if (marketFallback) {
636-
return { marketBadgeText: "partial / fallback data", marketBadgeClassName: "badge" };
627+
if (marketMeta?.source === "simulated" || marketFallback) {
628+
return { marketBadgeText: "Fallback data.", marketBadgeClassName: "badge badge-warning" };
637629
}
638630

639631
if (marketMeta?.source === "stooq") {
@@ -643,6 +635,13 @@ const DashboardPanels = () => {
643635
return { marketBadgeText: `delayed quotes (stooq)${partial}`, marketBadgeClassName: "badge" };
644636
}
645637

638+
if (marketMeta?.source && marketMeta.source !== "yahoo-finance") {
639+
return {
640+
marketBadgeText: `cached snapshot · source: ${marketMeta.source}`,
641+
marketBadgeClassName: "badge badge-warning",
642+
};
643+
}
644+
646645
return { marketBadgeText: null, marketBadgeClassName: "badge" };
647646
}, [isMarketLoading, marketFallback, marketMeta]);
648647

0 commit comments

Comments
 (0)