466 lines
19 KiB
JavaScript
466 lines
19 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 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),
|
||
};
|
||
}
|