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;
|
||
} |