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