finance-dashboard/lib/price-store.js

315 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 個股 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;
}