315 lines
10 KiB
JavaScript
315 lines
10 KiB
JavaScript
|
|
// 個股 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;
|
|||
|
|
}
|