finance-dashboard/lib/db.js

217 lines
8.6 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.

// ═══════════════════════════════════════════════════════════
// 本機資料庫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 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();
}
// ─── 通用 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; }
}
// ─── 交易復盤 ───
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),
};
}