finance-dashboard/lib/price-store.js

315 lines
10 KiB
JavaScript
Raw Permalink Normal View History

2026-06-04 09:32:28 +00:00
// 個股 OHLCV以 SQLite price_bars 為準Yahoo/Nasdaq 只補「還沒有的」K 線
import {
upsertPriceBars, getPriceBars, getPriceBarMeta, priceBarsToPoints, deletePriceBars,
getCachedEntry, putCachedJSON,
} from './db.js';
import { getHistory, getHistorySince } from './marketdata.js';
const META_PREFIX = 'histmeta:';
function metaKey(symbol, interval) {
return `${META_PREFIX}${symbol}:${interval}`;
}
function barPointCount(bars) {
return bars?.length || 0;
}
/** 從舊版 JSON 快取hist:SYM:range:1d匯入 DB避免重打 API */
export function importLegacyHistCaches(symbol, interval = '1d') {
const keys = [
`hist:${symbol}:max:${interval}`,
`hist:${symbol}:10y:${interval}`,
`hist:${symbol}:5y:${interval}`,
`hist:${symbol}:2y:${interval}`,
];
let best = null;
for (const key of keys) {
const entry = getCachedEntry(key);
const pts = entry?.value?.points;
if (!pts?.length) continue;
if (!best || pts.length > best.points.length) best = { key, entry, points: pts };
}
if (!best) return 0;
const n = upsertPriceBars(symbol, interval, best.points);
const v = best.entry.value;
putCachedJSON(metaKey(symbol, interval), {
symbol: v.symbol || symbol,
name: v.name || null,
currency: v.currency || null,
source: v.source || 'legacy-cache',
interval,
_importedFrom: best.key,
_fetchedAt: best.entry.updatedAt || Date.now(),
});
return n;
}
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
function startOfWeekISO(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
const diff = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
return d.toISOString().slice(0, 10);
}
/** 非即時研究:去掉可能尚未收盤的當根 K日線≤昨日、周線≤上週、月線≤上月 */
export function filterResearchBars(points, interval = '1d') {
if (!points?.length) return [];
const today = todayISO();
if (interval === '1d') {
return points.filter(p => p.date < today);
}
const last = points[points.length - 1];
if (interval === '1wk') {
if (startOfWeekISO(last.date) >= startOfWeekISO(today)) return points.slice(0, -1);
return points;
}
if (interval === '1mo') {
if (last.date.slice(0, 7) >= today.slice(0, 7)) return points.slice(0, -1);
return points;
}
return points;
}
const TTL_BY_INTERVAL = {
'1d': 6 * 3600 * 1000,
'1wk': 24 * 3600 * 1000,
'1mo': 7 * 24 * 3600 * 1000,
};
function weekKey(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - day);
const y = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const w = Math.ceil((((d - y) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(w).padStart(2, '0')}`;
}
function resampleBars(dailyPoints, interval) {
const keyFn = interval === '1wk'
? weekKey
: (dateStr) => dateStr.slice(0, 7);
const groups = new Map();
for (const p of dailyPoints) {
const k = keyFn(p.date);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(p);
}
const out = [];
for (const arr of [...groups.values()]) {
arr.sort((a, b) => (a.date < b.date ? -1 : 1));
const last = arr[arr.length - 1];
const highs = arr.map(x => x.high ?? x.close);
const lows = arr.map(x => x.low ?? x.close);
out.push({
date: last.date,
open: arr[0].open ?? arr[0].close,
high: Math.max(...highs),
low: Math.min(...lows),
close: last.close,
volume: arr.some(x => x.volume != null) ? arr.reduce((s, x) => s + (x.volume || 0), 0) : null,
adjclose: last.adjclose ?? last.close,
});
}
return out.sort((a, b) => (a.date < b.date ? -1 : 1));
}
async function fetchOrResample(symbol, interval) {
if (interval === '1d') return getHistory(symbol, 'max', '1d');
try {
const hist = await getHistory(symbol, 'max', interval);
if (hist.points?.length >= 2 && barsMatchInterval(
hist.points.map(p => ({ date: p.date })),
interval,
)) return hist;
} catch (_) { /* Yahoo 429 等 → 改本機重採樣 */ }
await ensurePriceHistory(symbol, '1d', { fresh: false });
const daily = priceBarsToPoints(getPriceBars(symbol, '1d'));
if (daily.length < 40) throw new Error('日線不足,無法合成周/月線');
const points = resampleBars(daily, interval);
if (points.length < 2) throw new Error('重採樣後資料過少');
return {
symbol,
name: null,
currency: null,
range: 'max',
interval,
source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線',
points,
};
}
/** 偵測 DB 是否誤存日線到周/月線 */
function barsMatchInterval(bars, interval) {
if (!bars?.length || interval === '1d') return true;
if (interval === '1wk' && bars.length > 600) return false;
if (interval === '1mo' && bars.length > 200) return false;
if (bars.length < 3) return true;
const i = bars.length - 1;
const gap = (new Date(bars[i].date) - new Date(bars[i - 1].date)) / 86400000;
if (interval === '1wk') return gap >= 4;
if (interval === '1mo') return gap >= 20;
return true;
}
function planFetch(bars, metaUpdatedAt, fresh, ttlMs, interval) {
if (!bars.length) return 'full';
if (fresh) return 'incremental';
const research = filterResearchBars(bars.map(b => ({ date: b.date })), interval);
const lastComplete = research.length ? research[research.length - 1].date : null;
const lastStored = bars[bars.length - 1].date;
const age = Date.now() - (metaUpdatedAt || 0);
if (lastComplete && lastStored > lastComplete && age > ttlMs / 2) return 'incremental';
if (lastComplete && lastComplete < todayISO() && age > ttlMs) return 'incremental';
if (age > ttlMs * 14) return 'incremental';
return null;
}
/**
* @returns {{ payload: object, cached: boolean, fetchMode: string|null }}
*/
export async function ensurePriceHistory(symbol, interval = '1d', { fresh = false, ttlMs } = {}) {
if (!ttlMs) ttlMs = TTL_BY_INTERVAL[interval] || TTL_BY_INTERVAL['1d'];
let bars = getPriceBars(symbol, interval);
if (!bars.length) importLegacyHistCaches(symbol, interval);
bars = getPriceBars(symbol, interval);
if (bars.length && !barsMatchInterval(bars, interval)) {
deletePriceBars(symbol, interval);
bars = [];
}
const mk = metaKey(symbol, interval);
let metaEntry = getCachedEntry(mk);
let meta = metaEntry?.value || { symbol, interval };
const mode = planFetch(bars, metaEntry?.updatedAt, fresh, ttlMs, interval);
let fetchError = null;
if (mode === 'full') {
try {
const hist = await fetchOrResample(symbol, interval);
upsertPriceBars(symbol, interval, hist.points);
meta = {
symbol: hist.symbol || symbol,
name: hist.name,
currency: hist.currency,
source: hist.source,
interval,
_fetchedAt: Date.now(),
_fetchMode: 'full',
};
putCachedJSON(mk, meta);
bars = getPriceBars(symbol, interval);
} catch (e) {
fetchError = String(e?.message || e);
if (!bars.length) throw e;
}
} else if (mode === 'incremental') {
const lastDate = bars[bars.length - 1].date;
try {
let patch;
try {
patch = await getHistorySince(symbol, lastDate, 'max', interval);
if (interval !== '1d' && !barsMatchInterval(patch.points.map(p => ({ date: p.date })), interval)) {
throw new Error('patch_not_weekly');
}
} catch {
const daily = priceBarsToPoints(getPriceBars(symbol, '1d'));
const resampled = resampleBars(daily, interval);
const idx = resampled.findIndex(p => p.date > lastDate);
patch = {
symbol,
interval,
points: idx >= 0 ? resampled.slice(idx) : [],
source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線',
};
}
const added = upsertPriceBars(symbol, interval, patch.points);
meta = {
...meta,
symbol: patch.symbol || symbol,
name: patch.name || meta.name,
currency: patch.currency || meta.currency,
source: patch.source || meta.source,
interval,
_fetchedAt: Date.now(),
_fetchMode: 'incremental',
_lastIncrementalAt: Date.now(),
_barsAdded: added,
};
putCachedJSON(mk, meta);
bars = getPriceBars(symbol, interval);
} catch (e) {
fetchError = String(e?.message || e);
}
}
const stat = getPriceBarMeta(symbol, interval);
const allPoints = priceBarsToPoints(bars);
const points = filterResearchBars(allPoints, interval);
const lastResearch = points.length ? points[points.length - 1].date : null;
return {
payload: {
symbol,
name: meta.name || null,
currency: meta.currency || null,
interval,
source: meta.source || 'MacroScope DB',
range: 'max',
points,
allBarsPoints: allPoints,
dbBars: stat.n,
researchBars: points.length,
firstDate: points[0]?.date || stat.first_date,
lastDate: lastResearch || stat.last_date,
researchThrough: lastResearch,
researchNote: interval === '1d'
? '研究用日線截至昨日完整 K 線(今日未收盤不納入)'
: interval === '1wk'
? '研究用周線截至上一根完整週 K'
: '研究用月線截至上一根完整月 K',
cached: mode == null,
fetchMode: mode,
fetchError,
},
cached: mode == null,
fetchMode: mode,
};
}
/** 成交量圖:在研究用 K 線之外,盡量附上「當日」成交量(來自 DB 當根或即時報價) */
export function buildVolumeSeries(researchPoints, allPoints, quote = {}, interval = '1d') {
if (interval !== '1d' || !researchPoints?.length) return researchPoints || [];
const today = todayISO();
let todayVol = quote?.volume != null ? Number(quote.volume) : null;
const rawToday = (allPoints || []).find(p => p.date === today);
if (rawToday?.volume != null) todayVol = Number(rawToday.volume);
const pts = researchPoints.map(p => ({ ...p }));
if (todayVol == null || isNaN(todayVol)) return pts;
const last = pts[pts.length - 1];
if (last?.date === today) {
pts[pts.length - 1] = { ...last, volume: todayVol };
return pts;
}
const px = quote?.price ?? quote?.regularMarketPrice ?? last?.close;
if (px == null) return pts;
pts.push({
date: today,
open: quote?.previousClose ?? px,
high: quote?.dayHigh ?? px,
low: quote?.dayLow ?? px,
close: px,
volume: todayVol,
adjclose: px,
partialSession: true,
});
return pts;
}