finance-dashboard/lib/db.js

466 lines
19 KiB
JavaScript
Raw Permalink 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.

// ═══════════════════════════════════════════════════════════
// 本機資料庫SQLite使用 Node 內建的 node:sqlite免安裝套件
// 存三種東西:
// 1. cache — 整包 /api/macro 結果,重啟伺服器可即時載入
// 2. series — 每個指標的完整歷史序列,供「走勢大圖」使用
// 3. score_history — 每天記一筆健康分數,累積成「分數走勢」
// 資料庫檔data.db已在 .gitignore 忽略)
// ═══════════════════════════════════════════════════════════
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.join(__dirname, '..', 'data.db');
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS series (
series_key TEXT NOT NULL,
date TEXT NOT NULL,
val REAL NOT NULL,
PRIMARY KEY (series_key, date)
);
CREATE TABLE IF NOT EXISTS score_history (
date TEXT PRIMARY KEY,
score INTEGER NOT NULL,
regime TEXT
);
CREATE TABLE IF NOT EXISTS price_bars (
symbol TEXT NOT NULL,
interval TEXT NOT NULL DEFAULT '1d',
date TEXT NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
adjclose REAL,
PRIMARY KEY (symbol, interval, date)
);
CREATE INDEX IF NOT EXISTS idx_price_bars_sym ON price_bars(symbol, interval, date);
CREATE TABLE IF NOT EXISTS company_intel_custom (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sec_filings (
symbol TEXT NOT NULL,
accession TEXT NOT NULL,
form TEXT,
form_zh TEXT,
filed_date TEXT,
report_date TEXT,
description TEXT,
primary_document TEXT,
url TEXT,
local_primary TEXT,
local_txt TEXT,
excerpt TEXT,
is_earnings_related INTEGER DEFAULT 0,
earnings_exhibits TEXT,
archived_at INTEGER,
PRIMARY KEY (symbol, accession)
);
CREATE INDEX IF NOT EXISTS idx_sec_filings_sym ON sec_filings(symbol, filed_date DESC);
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
event_date TEXT,
title TEXT,
title_zh TEXT,
time_label TEXT,
source TEXT,
url TEXT,
note TEXT,
kind TEXT,
accession TEXT,
transcript_search_url TEXT,
UNIQUE(symbol, event_date, kind, accession, title)
);
CREATE INDEX IF NOT EXISTS idx_earnings_sym ON earnings_events(symbol, event_date DESC);
CREATE TABLE IF NOT EXISTS sec_archive_meta (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS company_intel_enriched (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
sources TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
name TEXT,
direction TEXT NOT NULL DEFAULT 'long',
kind TEXT,
entry_date TEXT,
entry_price REAL,
shares REAL,
exit_date TEXT,
exit_price REAL,
entry_reason TEXT,
exit_reason TEXT,
principle TEXT,
mistake INTEGER DEFAULT 0,
mistake_note TEXT,
note TEXT,
created_at INTEGER,
updated_at INTEGER
);
`);
// ─── 整包結果的持久化快取 ───
export function savePayload(payload) {
db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)')
.run('macro', JSON.stringify(payload), Date.now());
}
export function loadPayload() {
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get('macro');
if (!row) return null;
try {
return { payload: JSON.parse(row.payload), updatedAt: row.updated_at };
} catch {
return null;
}
}
// ─── 指標歷史序列 ───
const insertPoint = db.prepare('INSERT OR REPLACE INTO series (series_key, date, val) VALUES (?, ?, ?)');
export function saveSeries(key, points) {
if (!points || points.length === 0) return;
db.exec('BEGIN');
try {
for (const p of points) insertPoint.run(key, p.date, p.val);
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
export function getSeries(key, sinceISO) {
if (sinceISO) {
return db.prepare('SELECT date, val FROM series WHERE series_key = ? AND date >= ? ORDER BY date ASC')
.all(key, sinceISO);
}
return db.prepare('SELECT date, val FROM series WHERE series_key = ? ORDER BY date ASC').all(key);
}
// ─── 每日健康分數快照(一天一筆,最新覆蓋)───
export function saveScoreSnapshot(score, regimeLabel) {
const today = new Date().toISOString().slice(0, 10);
db.prepare('INSERT OR REPLACE INTO score_history (date, score, regime) VALUES (?, ?, ?)')
.run(today, score, regimeLabel || null);
}
export function getScoreHistory() {
return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all();
}
// ─── 個股 OHLCV 日線長期累積API 只補缺口)───
const upsertBar = db.prepare(`
INSERT INTO price_bars (symbol, interval, date, open, high, low, close, volume, adjclose)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, interval, date) DO UPDATE SET
open=COALESCE(excluded.open, open),
high=COALESCE(excluded.high, high),
low=COALESCE(excluded.low, low),
close=COALESCE(excluded.close, close),
volume=COALESCE(excluded.volume, volume),
adjclose=COALESCE(excluded.adjclose, adjclose)
`);
function normBarPoint(p) {
const close = p.close != null ? Number(p.close) : null;
if (close == null || isNaN(close)) return null;
const adj = p.adjclose != null ? Number(p.adjclose) : close;
const o = p.open != null ? Number(p.open) : close;
const h = p.high != null ? Number(p.high) : close;
const l = p.low != null ? Number(p.low) : close;
const vol = p.volume != null ? Number(p.volume) : null;
return { date: p.date, open: o, high: h, low: l, close, volume: vol, adjclose: adj };
}
export function upsertPriceBars(symbol, interval, points) {
if (!symbol || !points?.length) return 0;
let n = 0;
db.exec('BEGIN');
try {
for (const raw of points) {
const p = normBarPoint(raw);
if (!p) continue;
upsertBar.run(symbol, interval, p.date, p.open, p.high, p.low, p.close, p.volume, p.adjclose);
n++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
return n;
}
export function getPriceBars(symbol, interval = '1d', sinceISO = null) {
if (sinceISO) {
return db.prepare(
'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? AND date>=? ORDER BY date ASC',
).all(symbol, interval, sinceISO);
}
return db.prepare(
'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? ORDER BY date ASC',
).all(symbol, interval);
}
export function deletePriceBars(symbol, interval = '1d') {
db.prepare('DELETE FROM price_bars WHERE symbol=? AND interval=?').run(symbol, interval);
}
export function getPriceBarMeta(symbol, interval = '1d') {
const row = db.prepare(
'SELECT COUNT(*) AS n, MIN(date) AS first_date, MAX(date) AS last_date FROM price_bars WHERE symbol=? AND interval=?',
).get(symbol, interval);
return row || { n: 0, first_date: null, last_date: null };
}
export function priceBarsToPoints(bars) {
return (bars || []).map(b => ({
date: b.date,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
adjclose: b.adjclose ?? b.close,
}));
}
// ─── 通用 JSON 快取(給財報健檢等,沿用 cache 表,含 TTL───
export function putCachedJSON(key, value) {
db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)')
.run(key, JSON.stringify(value), Date.now());
}
export function getCachedJSON(key, ttlMs) {
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get(key);
if (!row) return null;
if (ttlMs != null && Date.now() - row.updated_at > ttlMs) return null;
try { return JSON.parse(row.payload); } catch { return null; }
}
// 取出快取連同寫入時間(不做 TTL 判斷,由呼叫端決定新鮮度策略)
export function getCachedEntry(key) {
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get(key);
if (!row) return null;
try { return { value: JSON.parse(row.payload), updatedAt: row.updated_at }; } catch { return null; }
}
// ─── 價格走勢:自訂中文公司研究(管理層、新聞、簡介)───
export function getCompanyIntelCustom(symbol) {
const row = db.prepare('SELECT payload, updated_at FROM company_intel_custom WHERE symbol = ?').get(
String(symbol || '').trim().toUpperCase(),
);
if (!row) return null;
try {
return { data: JSON.parse(row.payload), updatedAt: row.updated_at };
} catch {
return null;
}
}
export function saveCompanyIntelCustom(symbol, data) {
const sym = String(symbol || '').trim().toUpperCase();
if (!sym) throw new Error('bad_symbol');
db.prepare('INSERT OR REPLACE INTO company_intel_custom (symbol, payload, updated_at) VALUES (?, ?, ?)')
.run(sym, JSON.stringify(data || {}), Date.now());
return { symbol: sym, updatedAt: Date.now() };
}
// ─── SEC 申報與財報/法說封存 ───
const upsertFilingStmt = db.prepare(`
INSERT INTO sec_filings (
symbol, accession, form, form_zh, filed_date, report_date, description,
primary_document, url, local_primary, local_txt, excerpt, is_earnings_related,
earnings_exhibits, archived_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, accession) DO UPDATE SET
form=excluded.form, form_zh=excluded.form_zh, filed_date=excluded.filed_date,
report_date=excluded.report_date, description=excluded.description,
primary_document=excluded.primary_document, url=excluded.url,
local_primary=COALESCE(excluded.local_primary, local_primary),
local_txt=COALESCE(excluded.local_txt, local_txt),
excerpt=COALESCE(excluded.excerpt, excerpt),
is_earnings_related=excluded.is_earnings_related,
earnings_exhibits=COALESCE(excluded.earnings_exhibits, earnings_exhibits),
archived_at=COALESCE(excluded.archived_at, archived_at)
`);
export function upsertSecFiling(row) {
const sym = String(row.symbol || '').trim().toUpperCase();
upsertFilingStmt.run(
sym, row.accession, row.form || null, row.formZh || null, row.filedDate || null,
row.reportDate || null, row.description || null, row.primaryDocument || null, row.url || null,
row.localPrimary || null, row.localTxt || null, row.excerpt || null,
row.isEarningsRelated ? 1 : 0, row.earningsExhibits || null, Date.now(),
);
}
export function listSecFilings(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
return db.prepare(
'SELECT symbol, accession, form, form_zh AS formZh, filed_date AS filedDate, report_date AS reportDate, description, primary_document AS primaryDocument, url, local_primary AS localPrimary, local_txt AS localTxt, excerpt, is_earnings_related AS isEarningsRelated, earnings_exhibits AS earningsExhibits, archived_at AS archivedAt FROM sec_filings WHERE symbol=? ORDER BY filed_date DESC, accession DESC',
).all(sym).map(r => ({
...r,
isEarningsRelated: !!r.isEarningsRelated,
earningsExhibits: r.earningsExhibits ? (() => { try { return JSON.parse(r.earningsExhibits); } catch { return null; } })() : null,
archivedAt: r.archivedAt ? new Date(r.archivedAt).toISOString() : null,
}));
}
const upsertEarnStmt = db.prepare(`
INSERT INTO earnings_events (symbol, event_date, title, title_zh, time_label, source, url, note, kind, accession, transcript_search_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, event_date, kind, accession, title) DO UPDATE SET
title_zh=excluded.title_zh, time_label=excluded.time_label, source=excluded.source,
url=COALESCE(excluded.url, url), note=excluded.note, transcript_search_url=excluded.transcript_search_url
`);
export function upsertEarningsEvent(row) {
const sym = String(row.symbol || '').trim().toUpperCase();
upsertEarnStmt.run(
sym, row.eventDate || null, row.title || null, row.titleZh || row.title || null,
row.timeLabel || '', row.source || null, row.url || null, row.note || '',
row.kind || 'calendar', row.accession || '', row.transcriptSearchUrl || null,
);
}
export function listEarningsEvents(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
return db.prepare(
'SELECT id, symbol, event_date AS eventDate, title, title_zh AS titleZh, time_label AS timeLabel, source, url, note, kind, accession, transcript_search_url AS transcriptSearchUrl FROM earnings_events WHERE symbol=? ORDER BY event_date DESC, id DESC LIMIT 80',
).all(sym);
}
export function getSecArchiveMeta(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
const row = db.prepare('SELECT payload, updated_at FROM sec_archive_meta WHERE symbol=?').get(sym);
if (!row) return null;
try {
const data = JSON.parse(row.payload);
return { ...data, lastSyncAt: data.lastSyncAt || row.updated_at };
} catch { return { lastSyncAt: row.updated_at }; }
}
export function saveSecArchiveMeta(symbol, data) {
const sym = String(symbol || '').trim().toUpperCase();
db.prepare('INSERT OR REPLACE INTO sec_archive_meta (symbol, payload, updated_at) VALUES (?, ?, ?)')
.run(sym, JSON.stringify(data || {}), Date.now());
}
export function getCompanyIntelEnriched(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
const row = db.prepare('SELECT payload, sources, updated_at FROM company_intel_enriched WHERE symbol=?').get(sym);
if (!row) return null;
try {
return {
data: JSON.parse(row.payload),
sources: row.sources ? JSON.parse(row.sources) : [],
updatedAt: row.updated_at,
};
} catch {
return null;
}
}
export function saveCompanyIntelEnriched(symbol, data, sources = []) {
const sym = String(symbol || '').trim().toUpperCase();
db.prepare('INSERT OR REPLACE INTO company_intel_enriched (symbol, payload, sources, updated_at) VALUES (?, ?, ?, ?)')
.run(sym, JSON.stringify(data || {}), JSON.stringify(sources || []), Date.now());
}
// ─── 交易復盤 ───
const TRADE_FIELDS = ['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares',
'exit_date', 'exit_price', 'entry_reason', 'exit_reason', 'principle', 'mistake', 'mistake_note', 'note'];
// 已實現損益 / 報酬率 / 持有天數(多空與否、是否平倉皆處理)
function computeTrade(t) {
const closed = t.exit_date != null && t.exit_price != null && t.entry_price != null;
const out = { ...t, closed };
out.cost = t.entry_price != null && t.shares != null ? t.entry_price * t.shares : null;
if (closed) {
const per = t.direction === 'short' ? (t.entry_price - t.exit_price) : (t.exit_price - t.entry_price);
out.pnl = per * t.shares;
const base = t.direction === 'short' ? t.exit_price : t.entry_price;
out.pnl_pct = base ? (per / t.entry_price) * 100 : null;
if (t.entry_date && t.exit_date) {
out.hold_days = Math.max(0, Math.round((new Date(t.exit_date) - new Date(t.entry_date)) / 86400000));
}
}
return out;
}
export function listTrades() {
const rows = db.prepare('SELECT * FROM trades ORDER BY COALESCE(exit_date, entry_date) DESC, id DESC').all();
return rows.map(computeTrade);
}
export function getTrade(id) {
const row = db.prepare('SELECT * FROM trades WHERE id = ?').get(id);
return row ? computeTrade(row) : null;
}
export function insertTrade(body) {
if (!body.symbol) throw new Error('缺少股票代號 symbol');
const now = Date.now();
const vals = TRADE_FIELDS.map(f => normField(f, body[f]));
const sql = `INSERT INTO trades (${TRADE_FIELDS.join(',')}, created_at, updated_at)
VALUES (${TRADE_FIELDS.map(() => '?').join(',')}, ?, ?)`;
const info = db.prepare(sql).run(...vals, now, now);
return getTrade(Number(info.lastInsertRowid));
}
export function updateTrade(id, body) {
const existing = db.prepare('SELECT id FROM trades WHERE id = ?').get(id);
if (!existing) return null;
const vals = TRADE_FIELDS.map(f => normField(f, body[f]));
const sql = `UPDATE trades SET ${TRADE_FIELDS.map(f => f + '=?').join(',')}, updated_at=? WHERE id=?`;
db.prepare(sql).run(...vals, Date.now(), id);
return getTrade(id);
}
export function deleteTrade(id) {
db.prepare('DELETE FROM trades WHERE id = ?').run(id);
}
function normField(f, v) {
if (v === undefined) v = null;
if (['entry_price', 'shares', 'exit_price'].includes(f)) return v === '' || v == null ? null : Number(v);
if (f === 'mistake') return v ? 1 : 0;
if (['exit_date', 'name', 'kind', 'entry_reason', 'exit_reason', 'principle', 'mistake_note', 'note'].includes(f))
return v === '' ? null : v;
if (f === 'direction') return v === 'short' ? 'short' : 'long';
return v;
}
// 復盤統計:勝率、賺賠比,並依「交易/投資」「是否犯錯」「依據心法」分組
export function tradeStats() {
const all = listTrades();
const closed = all.filter(t => t.closed);
const wins = closed.filter(t => t.pnl > 0);
const losses = closed.filter(t => t.pnl < 0);
const sum = arr => arr.reduce((a, t) => a + (t.pnl || 0), 0);
const avgWin = wins.length ? sum(wins) / wins.length : null;
const avgLoss = losses.length ? sum(losses) / losses.length : null;
const group = (keyFn) => {
const map = new Map();
for (const t of closed) {
const key = keyFn(t); if (key == null || key === '') continue;
if (!map.has(key)) map.set(key, []);
map.get(key).push(t);
}
return [...map.entries()].map(([key, arr]) => ({
key,
count: arr.length,
winRate: arr.length ? (arr.filter(t => t.pnl > 0).length / arr.length) * 100 : null,
pnl: sum(arr),
})).sort((a, b) => b.count - a.count);
};
return {
closed: closed.length,
open: all.length - closed.length,
wins: wins.length,
losses: losses.length,
winRate: closed.length ? (wins.length / closed.length) * 100 : null,
totalPnl: sum(closed),
avgWin, avgLoss,
payoff: avgWin != null && avgLoss ? avgWin / Math.abs(avgLoss) : null,
byKind: group(t => t.kind),
byMistake: group(t => (t.mistake ? '有犯錯' : '無犯錯')),
byPrinciple: group(t => t.principle ? t.principle.split('#').pop() : null),
};
}