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