// ═══════════════════════════════════════════════════════════ // 本機資料庫(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), }; }