2026-06-02 09:40:21 +00:00
// ═══════════════════════════════════════════════════════════
// 本機資料庫( 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
) ;
2026-06-04 09:32:28 +00:00
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
) ;
2026-06-03 09:21:58 +00:00
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
) ;
2026-06-02 09:40:21 +00:00
` );
// ─── 整包結果的持久化快取 ───
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 ( ) ;
}
2026-06-03 09:21:58 +00:00
2026-06-04 09:32:28 +00:00
// ─── 個股 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 ,
} ) ) ;
}
2026-06-03 09:21:58 +00:00
// ─── 通用 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 ; }
}
2026-06-04 09:32:28 +00:00
// ─── 價格走勢:自訂中文公司研究(管理層、新聞、簡介)───
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 ( ) ) ;
}
2026-06-03 09:21:58 +00:00
// ─── 交易復盤 ───
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 ) ,
} ;
}