曝險 BETA
Beta(曝險倍數)= 大盤漲跌 1% 時,你整個資產大約跟著變動幾 %。 Beta 1.4 就是大盤漲 1%、你賺約 1.4%;大盤跌 1%、你也賠約 1.4%。
怎麼算的:把每個部位的「佔比 × 槓桿倍數」加起來。 槓桿倍數依代號自動判斷 —— 結尾是 L 的正 2 ETF 算 2 倍,一般股票 1 倍, 現金 0 倍(放現金不會跟著大盤動,這就是現金能壓低曝險的原因)。
例:00631L 30% + 00685L 40% + 現金 30%
→ 0.30×2 + 0.40×2 + 0.30×0 = 1.40
設計 Beta= 你想維持的曝險水準,也就是這套策略的核心設定。 調高=更積極、波動更大;調低=更保守。
再平衡偏移 %= 容許實際偏離設計值多少才動作。 設 10%、設計值 1.4,容許範圍就是 1.26 ~ 1.54。 設太小會頻繁交易增加成本,設太大則失去控制曝險的意義。
為什麼要再平衡:股票漲的時候,它佔資產的比例自動變大, 曝險跟著升高——等於在高點承擔更多風險。再平衡就是把它拉回設定值。
簡單說:這是一套機械化的「漲了賣、跌了買」紀律, 靠固定的曝險目標決定何時動作,不用預測行情。
股數與成本一律以 Firstrade 匯入為準, 避免手動輸入與匯入紀錄重複計算。買賣請到「紀錄」頁匯入交易明細。
資產走勢每日自動寫入的總市值紀錄
損益變化
財務目標
需要資產 = 年提領 ÷ 提領率。護欄以目前股票市值反推實際提領率: 低於下界代表提領率偏高、該調降支出,高於上界則可調升(±20%,Guyton-Klinger 的護欄概念)。 這是試算,不是投資建議;提領率、通膨、稅費都會改變結果,槓桿 ETF 的波動與順序風險更會讓實際情況偏離試算。
壓力測試 · 自訂跌幅大盤下跌時,你的部位會怎麼樣
−20%市場水位0050 距離高點的跌幅
總經指標
台指期(TX)開盤前參考,日盤/夜盤各時段結束後更新
美股情緒指標
CNN Fear & Greed · 非官方端點,僅供參考怎麼用:0~100 分,越低越恐慌、越高越貪婪。 0–25 極度恐懼、25–45 恐懼、45–55 中性、 55–75 貪婪、75–100 極度貪婪。
傳統上當作反指標:市場過度恐懼時,歷史上常是加碼的時機; 過度貪婪時,可能提示短期回檔風險。
但這裡只當背景參考,不是訊號。 這套策略的價值在於機械化執行 Beta 訊號;拿情緒指數去決定「訊號來了要不要照做」, 等於退回主觀擇時,會抵銷掉規則化執行的意義。
怎麼算出來的:七個指標等權重合成,各自算出偏離自身平均值的程度, 正規化到 0~100 後平均:
匯入 Firstrade 交易明細
點一下紀錄可展開明細。刪除一筆會反向沖銷,並重算之後所有紀錄。配置快照在「試算」頁按「儲存目前配置」新增。
版本
網頁版本 Apps Script 狀態未檢查
Telegram 通知
先在 Telegram 找 @BotFather 輸入 /newbot 取得 token,貼上後 先對你的機器人傳一句話(否則它無權主動找你),再按「連線測試」。token 會存在 Apps Script, 網頁關著也能通知。盤中警示只看台股;美股數字放在每日摘要裡。
美股財報提醒
到 finnhub.io 免費註冊即可取得金鑰(不用付費方案)。持股表格會多一欄「財報日」,到期天數內會標紅並在試算頁提醒。 ETF 沒有財報,該欄留空。每天自動更新一次,也可以按上面的按鈕手動更新。
美股幣別顯示
只改變美股頁面的金額顯示;紀錄、成本與試算表同步仍以美元儲存,每股價格也維持美元。
跨裝置同步
用 Google 帳號登入即可跨裝置同步,不用再手動填網址或節點。
Google 試算表同步
var SCRIPT_VERSION = 'v4.0';
function doPost(e) {
var data = JSON.parse(e.postData.contents);
var ss = SpreadsheetApp.getActiveSpreadsheet();
writeSheet(ss, '交易紀錄',
['市場','時間','項目','類別','數量','價格','總金額','異動後股數','異動後現金'],
data.trades || []);
writeSheet(ss, '配置紀錄',
['時間','目標比例','設計Beta','偏移%','當下Beta'],
data.configs || []);
// 設計 Beta、偏移 % 與水位門檻存進指令碼屬性,定時通知才有判斷依據
if (data.config) {
var cp = PropertiesService.getScriptProperties();
['designBeta', 'deviationPct', 'zoneWatch', 'zoneCrash'].forEach(function (k) {
if (data.config[k] !== undefined && data.config[k] !== null) {
cp.setProperty('CFG_' + k, String(data.config[k]));
}
});
}
// 報價代號放在獨立欄位傳來(舊版指令碼會直接忽略,不會出錯)
var qsyms = data.quoteSyms || {};
var hold = (data.holdings || []).map(function (r) {
return r.concat([qsyms[r[0] + '|' + r[1]] || '']);
});
writeSheet(ss, '持股現況',
['市場','名稱','股數','現價','槓桿','現值','目標比例','報價代號'],
hold, [2, 8]);
return ContentService
.createTextOutput(JSON.stringify({ok: true, at: new Date().toISOString()}))
.setMimeType(ContentService.MimeType.JSON);
}
function doGet(e) {
var action = e && e.parameter ? e.parameter.action : '';
// 版本探測:前端用這個確認 Apps Script 是不是最新版,不碰任何外部資料。
// 這支必須擺在第一個分支——它曾在某次改版時被誤刪,導致前端測出來的
// 版本永遠是舊的萬用回覆,不管實際部署了什麼都測不出正確結果。
if (action === 'ping') {
var daily = null;
try { daily = snapTriggers().length > 0; } catch (err) { /* 尚未授權定時觸發 */ }
return ContentService.createTextOutput(JSON.stringify({
ok: true, version: SCRIPT_VERSION, daily: daily,
features: ['quote', 'macro', 'level', 'usquote', 'feargreed', 'txf', 'twchange', 'daily', 'tr', 'idx', 'telegram']
})).setMimeType(ContentService.MimeType.JSON);
}
// 每日資產:開關定時觸發、立即記錄一次、讀取歷史紀錄
if (action === 'dailyTrigger') {
try {
var on = (e.parameter.on === '1');
setDailyTrigger(on);
return jsonOut({ ok: true, daily: on });
} catch (err) {
return jsonOut({ error: String(err) });
}
}
if (action === 'snapshot') {
try { return jsonOut({ ok: true, written: takeSnapshot().written }); }
catch (err) { return jsonOut({ error: String(err) }); }
}
// Telegram:存設定、找出 chat id、發測試訊息
if (action === 'telegram') {
try { return jsonOut(telegramSetup(e.parameter)); }
catch (err) { return jsonOut({ error: String(err) }); }
}
// 指數歷史:讀取、補抓舊資料(一次一段,可重複呼叫)
if (action === 'indexHistory') {
try { return jsonOut({ rows: readIndexHistory() }); }
catch (err) { return jsonOut({ error: String(err) }); }
}
if (action === 'indexBackfill') {
try { return jsonOut(backfillIndex(parseInt(e.parameter.days || '120', 10))); }
catch (err) { return jsonOut({ error: String(err) }); }
}
if (action === 'history') {
try { return jsonOut({ rows: readHistory() }); }
catch (err) { return jsonOut({ error: String(err) }); }
}
// 報價中轉:瀏覽器不能直連證交所(CORS),改由這裡代抓
if (action === 'quote') {
// 台股報價。證交所的 mis API 會封鎖 Google 伺服器 IP,改用試算表內建的
// GOOGLEFINANCE(台股前綴 TPE:)。回傳格式沿用 msgArray,前端不必改。
var ex = (e.parameter.ex || '');
var codes = [];
var parts = ex.split('|');
for (var i = 0; i < parts.length; i++) {
var m = parts[i].match(/(?:tse_|otc_)?([A-Za-z0-9]+)\.tw/i);
if (m) codes.push(m[1].toUpperCase());
}
if (!codes.length) {
return ContentService.createTextOutput(JSON.stringify({ msgArray: [] }))
.setMimeType(ContentService.MimeType.JSON);
}
// withChange=1 另外附上今日漲跌(chg)與漲跌幅 %(chp),供「今日變動」使用;
// 省略時回傳格式與舊版完全相同。
var twChange = (e.parameter.withChange === '1');
var prices = twPrices(codes, twChange);
var arr = [];
for (var k = 0; k < codes.length; k++) {
var info = prices[codes[k]];
var px = twChange ? (info && info.price) : info;
if (!(px > 0)) continue;
var item = { c: codes[k], z: String(px) };
if (twChange && info.change !== null) item.chg = info.change;
if (twChange && info.changepct !== null) item.chp = info.changepct;
if (twChange && info.src) item.src = info.src;
arr.push(item);
}
return ContentService.createTextOutput(JSON.stringify({ msgArray: arr }))
.setMimeType(ContentService.MimeType.JSON);
}
// 美股報價:借用試算表內建的 GOOGLEFINANCE。不需金鑰,也不會像外部網站
// 那樣封鎖 Google 的伺服器 IP(Stooq 就是因此抓不到)
if (action === 'usquote') {
var syms = (e.parameter.symbols || '').split(',').filter(String);
// withChange=1 加上今日開盤/漲跌,前端用於顯示「今日變動」;省略則維持原本
// 純數字格式,不影響其他呼叫端。
var wantChange = (e.parameter.withChange === '1');
var out = googleFinancePrices(syms, '', wantChange);
var payload = { quotes: out, limited: false, source: 'GOOGLEFINANCE' };
if (e.parameter.debug === '1') payload.debug = syms;
return ContentService
.createTextOutput(JSON.stringify(payload))
.setMimeType(ContentService.MimeType.JSON);
}
// 恐懼貪婪指數:CNN 內部端點,非官方公開 API,格式可能隨時改變。
// 失敗時回傳 error,前端會顯示「暫時無法取得」而不是讓整個頁面壞掉。
// VIX:CNN 端點被封鎖後的替代方案,用 GOOGLEFINANCE 試試看。
// 不保證一定抓得到,失敗時前端會顯示明確訊息,不影響其他功能。
if (action === 'vix') {
try {
var v = googleFinancePrices(['INDEXCBOE:VIX'], '');
var price = v['INDEXCBOE:VIX'];
if (!(price > 0)) throw new Error('GOOGLEFINANCE 沒有回傳有效數值');
return ContentService.createTextOutput(JSON.stringify({ vix: price }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ error: String(err) }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 台指期:期交所官方 Open API,只回傳「最新一個交易時段」的資料
// (日盤收盤後才有當天日盤、夜盤結束後才有當天夜盤),不是逐筆跳動的即時報價。
if (action === 'txf') {
try {
var res = UrlFetchApp.fetch('https://openapi.taifex.com.tw/v1/DailyMarketReportFut',
{ muteHttpExceptions: true });
var rows = JSON.parse(res.getContentText());
if (!(rows instanceof Array)) throw new Error('unexpected response shape');
// 只留台股期貨 TX,且排除價差委託月份(含 '/' 的月份代碼)
var tx = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
if (r['Contract'] === 'TX' && String(r['ContractMonth(Week)'] || '').indexOf('/') < 0) {
tx.push(r);
}
}
if (!tx.length) throw new Error('no TX rows found');
// 近月合約=到期月份最早的那一檔(避免抓到成交量小的遠月)
tx.sort(function (a, b) {
return String(a['ContractMonth(Week)']).localeCompare(String(b['ContractMonth(Week)']));
});
var nearMonth = tx[0]['ContractMonth(Week)'];
var nearRows = tx.filter(function (r) { return r['ContractMonth(Week)'] === nearMonth; });
function pick(session) {
var row = nearRows.filter(function (r) { return r['TradingSession'] === session; })[0];
if (!row) return null;
return {
last: parseFloat(row['Last']),
change: parseFloat(row['Change']),
pct: parseFloat(row['%']),
date: row['Date']
};
}
var out = {
contractMonth: nearMonth,
day: pick('一般'),
night: pick('盤後')
};
return ContentService.createTextOutput(JSON.stringify(out))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ error: String(err) }))
.setMimeType(ContentService.MimeType.JSON);
}
}
if (action === 'feargreed') {
try {
var d = new Date();
d.setDate(d.getDate() - 10);
var iso = Utilities.formatDate(d, 'UTC', 'yyyy-MM-dd');
var url = 'https://production.dataviz.cnn.io/index/fearandgreed/graphdata/' + iso;
var res = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
'Referer': 'https://edition.cnn.com/markets/fear-and-greed'
}
});
var data = JSON.parse(res.getContentText());
var fg = data.fear_and_greed;
if (!fg || typeof fg.score !== 'number') throw new Error('unexpected response shape');
return ContentService.createTextOutput(JSON.stringify({
score: Math.round(fg.score * 10) / 10,
rating: fg.rating || '',
at: fg.timestamp || null,
prevClose: fg.previous_close || null,
prevWeek: fg.previous_1_week || null,
prevMonth: fg.previous_1_month || null,
prevYear: fg.previous_1_year || null
})).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ error: String(err) }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 總經參考:抓證交所 OpenAPI,算好摘要再回傳(避免傳整包上千筆)
if (action === 'macro') {
try {
var out = {};
var idx = JSON.parse(UrlFetchApp.fetch(
'https://openapi.twse.com.tw/v1/exchangeReport/MI_INDEX',
{ muteHttpExceptions: true }).getContentText());
for (var i = 0; i < idx.length; i++) {
var nm = idx[i]['指數'];
// 加權指數(不含息)與加權報酬指數(含息)都在同一份回應裡
if (nm === '發行量加權股價指數' || nm === '發行量加權股價報酬指數' || nm === '臺灣50報酬指數') {
var sign = (idx[i]['漲跌'] === '-') ? -1 : 1;
var val = {
close: idx[i]['收盤指數'],
changePct: sign * parseFloat(idx[i]['漲跌百分比'] || 0)
};
if (nm === '發行量加權股價指數') out.taiex = val;
else if (nm === '發行量加權股價報酬指數') out.taiexTR = val;
else out.t50TR = val;
if (out.taiex && out.taiexTR && out.t50TR) break;
}
}
var bw = JSON.parse(UrlFetchApp.fetch(
'https://openapi.twse.com.tw/v1/exchangeReport/BWIBBU_ALL',
{ muteHttpExceptions: true }).getContentText());
var pe = [], pb = [], dy = [];
for (var j = 0; j < bw.length; j++) {
var a = parseFloat(bw[j].PEratio), b = parseFloat(bw[j].PBratio), c = parseFloat(bw[j].DividendYield);
if (a > 0) pe.push(a);
if (b > 0) pb.push(b);
if (c > 0) dy.push(c);
}
out.pe = median(pe);
out.pb = median(pb);
out.yield = median(dy);
out.count = bw.length;
// level=0 代表前端已有資料,略過這段(抓 60 個月很慢)
if (e.parameter.level !== '0') {
out.level = marketLevel(parseInt(e.parameter.months || '60', 10));
}
return ContentService.createTextOutput(JSON.stringify(out))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ error: String(err) }))
.setMimeType(ContentService.MimeType.JSON);
}
}
return ContentService
.createTextOutput(JSON.stringify({ok: true, msg: '連線正常'}))
.setMimeType(ContentService.MimeType.JSON);
}
// 0050 收盤序列。2025-06-18 一拆四,之前的價格要除以 4 才能跟現在比較
var SPLITS_0050 = [{ date: '20250618', ratio: 4 }];
var HIST_SHEET = '0050歷史';
var QUOTE_SHEET = '_報價暫存';
// 抓過的月份存進試算表,之後只補缺的月份(5 年 = 60 個月,每次重抓太慢)
function marketLevel(months) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(HIST_SHEET);
if (!sh) {
sh = ss.insertSheet(HIST_SHEET);
sh.getRange(1, 1, 1, 2).setValues([['日期', '收盤價(原始)']]).setFontWeight('bold');
sh.setFrozenRows(1);
}
// 日期欄一定要是純文字,否則試算表會把 2022-09-28 轉成日期物件,排序與分割還原都會壞掉
sh.getRange('A:A').setNumberFormat('@');
var have = {}, rows = [];
var last = sh.getLastRow();
if (last > 1) {
var vals = sh.getRange(2, 1, last - 1, 2).getValues();
for (var i = 0; i < vals.length; i++) {
var d = toIso(vals[i][0]), c = parseFloat(vals[i][1]);
if (d && c > 0) { have[d] = c; rows.push([d, c]); }
}
}
var now = new Date();
var thisYM = now.getFullYear() + ('0' + (now.getMonth() + 1)).slice(-2);
var monthsHeld = {};
for (var k in have) monthsHeld[k.slice(0, 4) + k.slice(5, 7)] = true;
var added = 0;
for (var m = months - 1; m >= 0; m--) {
var dt = new Date(now.getFullYear(), now.getMonth() - m, 1);
var ym = dt.getFullYear() + ('0' + (dt.getMonth() + 1)).slice(-2);
// 已抓過且不是當月就跳過;當月每次都更新
if (monthsHeld[ym] && ym !== thisYM) continue;
try {
var url = 'https://www.twse.com.tw/exchangeReport/STOCK_DAY?response=json&date='
+ ym + '01&stockNo=0050';
var r = JSON.parse(UrlFetchApp.fetch(url, { muteHttpExceptions: true }).getContentText());
if (r && r.data) {
for (var j = 0; j < r.data.length; j++) {
var roc = r.data[j][0].split('/');
var iso = (parseInt(roc[0], 10) + 1911) + '-' + roc[1] + '-' + roc[2];
var close = parseFloat(String(r.data[j][6]).replace(/,/g, ''));
if (close > 0 && !have[iso]) { have[iso] = close; rows.push([iso, close]); added++; }
}
}
Utilities.sleep(250);
} catch (err) { /* 略過抓不到的月份 */ }
}
rows.sort(function (a, b) { return a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : 0); });
if (added > 0) {
sh.getRange(2, 1, Math.max(rows.length, 1), 2).setValues(rows);
}
// 只取要求的區間,並做分割還原
var cutoff = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
var cutIso = cutoff.getFullYear() + '-' + ('0' + (cutoff.getMonth() + 1)).slice(-2) + '-01';
var series = [];
for (var n = 0; n < rows.length; n++) {
if (rows[n][0] >= cutIso) series.push({ d: rows[n][0], c: adjust(rows[n][1], rows[n][0]) });
}
if (!series.length) return null;
var high = series[0].c, highDate = series[0].d;
for (var q = 1; q < series.length; q++) {
if (series[q].c > high) { high = series[q].c; highDate = series[q].d; }
}
var lastPt = series[series.length - 1];
return {
series: series, high: high, highDate: highDate,
current: lastPt.c, currentDate: lastPt.d, months: months, cached: rows.length
};
}
// 試算表可能回傳 Date 物件,統一轉回 yyyy-MM-dd 字串
function toIso(v) {
if (v instanceof Date) return Utilities.formatDate(v, 'Asia/Taipei', 'yyyy-MM-dd');
var t = String(v).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t;
// 試算表的日期序號(1899-12-30 起算的天數)。直接 new Date('45665') 會被
// 當成「西元 45665 年」,畫面就出現 45665-01-01。
if (/^\d{4,6}(\.\d+)?$/.test(t)) {
var serial = parseFloat(t);
if (serial > 20000 && serial < 80000) {
return Utilities.formatDate(new Date(Math.round((serial - 25569) * 86400000)),
'Asia/Taipei', 'yyyy-MM-dd');
}
}
var d = new Date(t);
if (!isNaN(d.getTime())) return Utilities.formatDate(d, 'Asia/Taipei', 'yyyy-MM-dd');
return '';
}
// 把分割日之前的價格換算成現在的計價基礎
function adjust(price, iso) {
var ymd = iso.replace(/-/g, '');
for (var i = 0; i < SPLITS_0050.length; i++) {
if (ymd < SPLITS_0050[i].date) price = price / SPLITS_0050[i].ratio;
}
return Math.round(price * 100) / 100;
}
// 用試算表的 GOOGLEFINANCE 查價:寫公式、等算完、讀值、清空。
// prefix 用於台股(TPE:),美股留空。
// withChange=true 額外抓開盤價/漲跌金額/漲跌百分比(GOOGLEFINANCE 官方屬性:
// priceopen, change, changepct),供「今日變動」欄位使用;預設 false 時行為不變,
// 只抓 price,回傳值維持單純數字,不影響既有呼叫端(台股報價等)。
function googleFinancePrices(syms, prefix, withChange) {
var out = {};
if (!syms || !syms.length) return out;
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(QUOTE_SHEET);
if (!sh) { sh = ss.insertSheet(QUOTE_SHEET); sh.hideSheet(); }
sh.clear();
var attrs = withChange ? ['price', 'priceopen', 'change', 'changepct'] : ['price'];
var rows = []; // [label, f1, f2, ...] written to the sheet
var labels = []; // labels kept in memory -- the source of truth, not the round-tripped sheet cell
for (var i = 0; i < syms.length && i < 60; i++) {
var t = String(syms[i]).trim().toUpperCase().replace(/"/g, '');
if (!t) continue;
// 已自帶交易所前綴(例如 BATS:DRAM)就不再加
var q = (t.indexOf(':') >= 0) ? t : (prefix + t);
var row = [t];
for (var a = 0; a < attrs.length; a++) {
row.push('=IFERROR(GOOGLEFINANCE("' + q + '","' + attrs[a] + '"),"NA")');
}
rows.push(row);
labels.push(t);
}
if (!rows.length) return out;
var cols = 1 + attrs.length;
// Column A is display-only bookkeeping. Sheets auto-converts a numeric-looking
// label like "0050" to the number 50, silently dropping the leading zero --
// that's exactly why 0050 stopped matching while 00631L (has a letter, stays
// text) kept working. Force text format as a defence, and -- belt and braces --
// never re-read column A as the key anyway; use `labels` captured before the
// sheet touched anything.
sh.getRange(1, 1, rows.length, 1).setNumberFormat('@');
sh.getRange(1, 1, rows.length, cols).setValues(rows);
SpreadsheetApp.flush();
var vals = [];
for (var tries = 0; tries < 6; tries++) {
Utilities.sleep(700);
vals = sh.getRange(1, 1, rows.length, cols).getValues();
var pending = 0;
for (var v = 0; v < vals.length; v++) {
for (var c = 1; c < cols; c++) {
if (String(vals[v][c]).indexOf('Loading') >= 0) pending++;
}
}
if (!pending) break;
}
for (var k = 0; k < vals.length; k++) {
var price = parseFloat(vals[k][1]);
if (!(price > 0)) continue;
if (!withChange) {
out[labels[k]] = price;
} else {
var open = parseFloat(vals[k][2]);
var change = parseFloat(vals[k][3]);
var changepct = parseFloat(vals[k][4]);
out[labels[k]] = {
price: price,
open: (open > 0) ? open : null,
change: isNaN(change) ? null : change,
changepct: isNaN(changepct) ? null : changepct
};
}
}
sh.clear();
return out;
}
// Yahoo 的盤中報價(同樣約延遲 20 分,跟 GOOGLEFINANCE 一個等級)。上市試 .TW,
// 上櫃試 .TWO;查無代號會回 404。GOOGLEFINANCE 查不到 0050 之後,台股的
// 盤中價就靠這裡,不然只剩前一日收盤,算出來的 Beta 和再平衡建議會偏掉。
function yahooTwQuote(code) {
var sfx = ['.TW', '.TWO'];
for (var i = 0; i < sfx.length; i++) {
try {
var res = UrlFetchApp.fetch('https://query1.finance.yahoo.com/v8/finance/chart/'
+ encodeURIComponent(code + sfx[i]) + '?interval=1d&range=1d',
{ muteHttpExceptions: true, headers: { 'User-Agent': 'Mozilla/5.0' } });
if (res.getResponseCode() !== 200) continue;
var r = JSON.parse(res.getContentText());
var meta = r && r.chart && r.chart.result && r.chart.result[0] && r.chart.result[0].meta;
if (!meta || !(meta.regularMarketPrice > 0)) continue;
return {
price: meta.regularMarketPrice,
change: (typeof meta.fulldayChange === 'number') ? meta.fulldayChange : null,
changepct: (typeof meta.regularMarketChangePercent === 'number') ? meta.regularMarketChangePercent : null,
src: 'yahoo'
};
} catch (err) { /* 換下一個後綴 */ }
}
return null;
}
// 證交所官方日收盤。GOOGLEFINANCE 把 0050 當成「基金」,price 屬性沒有值,
// 所以台股抓不到價的代號改問這支官方端點(它在 Google 的伺服器上抓得到,
// 0050 歷史也是用同一支)。當月還沒有資料就往前一個月找。
function twseLastClose(code) {
var now = new Date();
for (var back = 0; back < 2; back++) {
var dt = new Date(now.getFullYear(), now.getMonth() - back, 1);
var ym = dt.getFullYear() + ('0' + (dt.getMonth() + 1)).slice(-2);
try {
var r = JSON.parse(UrlFetchApp.fetch(
'https://www.twse.com.tw/exchangeReport/STOCK_DAY?response=json&date='
+ ym + '01&stockNo=' + encodeURIComponent(code),
{ muteHttpExceptions: true }).getContentText());
if (r && r.data && r.data.length) {
var row = r.data[r.data.length - 1];
var close = parseFloat(String(row[6]).replace(/,/g, ''));
// 漲跌價差:"+0.85" / "-0.10" / "X0.00"(X 表示不比價)
var diff = parseFloat(String(row[7]).replace(/[,X+]/g, ''));
if (close > 0) {
var prev = isNaN(diff) ? 0 : close - diff;
return {
price: close,
change: isNaN(diff) ? null : diff,
changepct: (prev > 0 && !isNaN(diff)) ? diff / prev * 100 : null,
date: String(row[0]), src: 'twse'
};
}
}
} catch (err) { /* 換上一個月再試 */ }
}
return null;
}
// 台股價格的單一入口:GOOGLEFINANCE → Yahoo 盤中 → 證交所官方收盤。
// src:'twse' 讓前端知道那一檔是收盤價而不是盤中價(Yahoo 是盤中價,不標註)。
function twPrices(codes, withChange) {
var out = googleFinancePrices(codes, 'TPE:', withChange);
for (var i = 0; i < codes.length; i++) {
var c = String(codes[i]).trim().toUpperCase();
if (!c || out[c]) continue;
var q = yahooTwQuote(c) || twseLastClose(c);
if (!q) continue;
out[c] = withChange
? { price: q.price, open: null, change: q.change, changepct: q.changepct, src: q.src }
: q.price;
}
return out;
}
function median(arr) {
if (!arr.length) return null;
arr.sort(function (x, y) { return x - y; });
var m = Math.floor(arr.length / 2);
var v = (arr.length % 2) ? arr[m] : (arr[m - 1] + arr[m]) / 2;
return Math.round(v * 100) / 100;
}
// textCols:要強制文字格式的欄(從 1 起算)。代號欄不設的話,
// 試算表會把 0050 自動轉成數字 50。
function writeSheet(ss, name, header, rows, textCols) {
var sh = ss.getSheetByName(name) || ss.insertSheet(name);
sh.clear();
sh.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold');
if (rows.length) {
(textCols || []).forEach(function (c) {
sh.getRange(2, c, rows.length, 1).setNumberFormat('@');
});
sh.getRange(2, 1, rows.length, header.length).setValues(rows);
}
sh.setFrozenRows(1);
}
function jsonOut(o) {
return ContentService.createTextOutput(JSON.stringify(o))
.setMimeType(ContentService.MimeType.JSON);
}
// ───── 每日資產 ─────
// 每個交易日 14 點後(台北時間)讀「持股現況」、重抓收盤價,
// 把兩個市場的市值、現金、Beta 追加一列到「每日資產」。網頁沒開也會執行。
var SNAP_SHEET = '每日資產';
var SNAP_TZ = 'Asia/Taipei';
// 在編輯器選這個函式按「執行」一次,就會跳出定時觸發需要的授權。
// 更新部署版本時 Google 不會主動要求新權限,所以要手動跑一次。
function authorize() {
var n = ScriptApp.getProjectTriggers().length;
Logger.log('授權完成,目前有 ' + n + ' 個定時觸發');
}
function snapTriggers() {
return ScriptApp.getProjectTriggers().filter(function (t) {
return t.getHandlerFunction() === 'dailySnapshotTrigger';
});
}
function setDailyTrigger(on) {
snapTriggers().forEach(function (t) { ScriptApp.deleteTrigger(t); });
setIntradayTrigger(on); // 盤中檢查跟著每日寫入一起開關
if (on) {
// 台股 13:30 收盤,GOOGLEFINANCE 約延遲 20 分鐘,所以排在 14 點
ScriptApp.newTrigger('dailySnapshotTrigger').timeBased()
.everyDays(1).atHour(14).inTimezone(SNAP_TZ).create();
}
}
// 定時觸發的進入點:週末不記錄(國定假日仍會記一筆,數字與前一天相同)
function dailySnapshotTrigger() {
var dow = Number(Utilities.formatDate(new Date(), SNAP_TZ, 'u')); // 1=週一 … 7=週日
if (dow >= 6) return;
try {
try { marketLevel(1); } catch (e) { /* 0050 當月收盤抓不到就沿用舊資料 */ }
var snap = takeSnapshot();
if (!snap.written.length) throw new Error('沒有寫入任何一列,請確認「持股現況」有資料');
dailyReport(snap);
} catch (err) {
tgError('每日寫入失敗', err);
throw err; // 仍讓 Apps Script 記一筆失敗執行
}
}
function takeSnapshot() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var src = ss.getSheetByName('持股現況');
if (!src || src.getLastRow() < 2) throw new Error('找不到「持股現況」,請先在網頁按「立即同步」');
var width = Math.min(8, src.getLastColumn());
var vals = src.getRange(2, 1, src.getLastRow() - 1, width).getValues();
var mk = {};
vals.forEach(function (r) {
var label = String(r[0] || '');
if (!label) return;
var m = mk[label] || (mk[label] = { items: [], cash: 0 });
if (r[2] === '' || r[2] === null) { m.cash += Number(r[5]) || 0; return; } // 現金列沒有股數
m.items.push({ name: String(r[1]).trim(), sym: String(r[7] || '').trim(),
qty: Number(r[2]) || 0, price: Number(r[3]) || 0, lev: Number(r[4]) || 1 });
});
// 重抓收盤價;抓不到的沿用上次同步時的價格
var fx = '';
if (mk['台股'] && mk['台股'].items.length) {
var tp = twPrices(mk['台股'].items.map(function (i) { return i.name; }));
mk['台股'].items.forEach(function (i) {
var p = tp[i.name.toUpperCase()];
if (p > 0) i.price = p;
});
}
if (mk['美股']) {
var keys = mk['美股'].items.map(function (i) { return (i.sym || i.name).toUpperCase(); });
var up = googleFinancePrices(keys.concat(['CURRENCY:USDTWD']), '');
mk['美股'].items.forEach(function (i, k) {
var p = up[keys[k]];
if (p > 0) i.price = p;
});
if (up['CURRENCY:USDTWD'] > 0) fx = up['CURRENCY:USDTWD'];
// 部分帳號的 GOOGLEFINANCE 抓不到 CURRENCY:USDTWD,改用公開匯率 API
if (!fx) {
try {
var er = JSON.parse(UrlFetchApp.fetch('https://open.er-api.com/v6/latest/USD',
{ muteHttpExceptions: true }).getContentText());
if (er && er.rates && er.rates.TWD > 0) fx = er.rates.TWD;
} catch (err) { /* 匯率欄留空,網頁會改用當下匯率換算 */ }
}
}
var sh = ss.getSheetByName(SNAP_SHEET);
if (!sh) {
sh = ss.insertSheet(SNAP_SHEET);
sh.getRange(1, 1, 1, 8).setValues([['日期', '市場', '股票市值', '現金', '總市值', 'Beta', '美元匯率', '明細']])
.setFontWeight('bold');
sh.setFrozenRows(1);
}
sh.getRange('A:A').setNumberFormat('@');
var date = Utilities.formatDate(new Date(), SNAP_TZ, 'yyyy-MM-dd');
var last = sh.getLastRow();
var existing = last > 1 ? sh.getRange(2, 1, last - 1, 2).getValues() : [];
var written = [], data = {};
Object.keys(mk).forEach(function (label) {
var m = mk[label];
var stock = 0, exposure = 0;
m.items.forEach(function (i) { var v = i.qty * i.price; stock += v; exposure += v * i.lev; });
var total = stock + m.cash;
var beta = (label === '台股' && total > 0) ? Math.round(exposure / total * 1000) / 1000 : '';
var detail = m.items.map(function (i) { return i.name + ' ' + i.qty + '股@' + i.price; }).join('; ');
var row = [date, label, round2(stock), round2(m.cash), round2(total), beta, label === '美股' ? fx : '', detail];
// 同一天同市場重跑就覆蓋那一列,不重複追加
var at = -1;
for (var n = 0; n < existing.length; n++) {
if (toIso(existing[n][0]) === date && String(existing[n][1]) === label) { at = n + 2; break; }
}
if (at > 0) sh.getRange(at, 1, 1, 8).setValues([row]);
else {
sh.appendRow(row);
sh.getRange(sh.getLastRow(), 1).setNumberFormat('@').setValue(date);
}
written.push(label);
data[label] = { stock: stock, cash: m.cash, total: total, beta: beta };
});
recordIndexDay(new Date()); // 同一趟把當天的報酬指數也記下來
return { written: written, data: data, fx: fx };
}
// ───── Telegram 通知 ─────
// 盤中每 30 分檢查 0050 距高點的跌幅,跌進觀察/股災區就通知;收盤後 14:00
// 用當天的收盤資料發摘要、檢查 Beta,並在流程出錯時回報。
function props() { return PropertiesService.getScriptProperties(); }
function prop(k, dflt) { var v = props().getProperty(k); return (v === null || v === '') ? dflt : v; }
function tgCfg() {
return {
token: prop('TG_TOKEN', ''), chat: prop('TG_CHAT', ''),
intraday: prop('TG_INTRADAY', '1') !== '0',
beta: prop('TG_BETA', '1') !== '0',
daily: prop('TG_DAILY', '1') !== '0'
};
}
function tgSend(text) {
var c = tgCfg();
if (!c.token || !c.chat) return false;
var res = UrlFetchApp.fetch('https://api.telegram.org/bot' + c.token + '/sendMessage', {
method: 'post', muteHttpExceptions: true,
payload: { chat_id: c.chat, text: text, disable_web_page_preview: 'true' }
});
return res.getResponseCode() === 200;
}
// 問 Telegram 這個 token 是哪個機器人,用來確認使用者對話的是同一個帳號
function tgWho(token) {
try {
var r = JSON.parse(UrlFetchApp.fetch('https://api.telegram.org/bot' + token + '/getMe',
{ muteHttpExceptions: true }).getContentText());
return (r.ok && r.result && r.result.username) ? '@' + r.result.username : '帳號不明';
} catch (e) { return '帳號不明'; }
}
// 存設定;chat id 由 getUpdates 自動找出(使用者要先對機器人講過一句話)
function telegramSetup(p) {
var pr = props();
if (p.token) pr.setProperty('TG_TOKEN', String(p.token).trim());
['intraday', 'beta', 'daily'].forEach(function (k) {
if (p[k] !== undefined) pr.setProperty('TG_' + k.toUpperCase(), p[k] === '0' ? '0' : '1');
});
var token = prop('TG_TOKEN', '');
if (!token) return { error: '尚未填入 token' };
var chat = prop('TG_CHAT', '');
if (!chat || p.rechat === '1') {
var res = UrlFetchApp.fetch('https://api.telegram.org/bot' + token + '/getUpdates',
{ muteHttpExceptions: true });
var body = JSON.parse(res.getContentText());
if (!body.ok) return { error: 'Telegram 拒絕連線:' + (body.description || 'token 可能不正確') };
var list = body.result || [];
for (var i = list.length - 1; i >= 0; i--) {
var u = list[i];
// 按鈕啟動、加進群組、編輯訊息都算「講過話」,只要帶著 chat 就收
var msg = u.message || u.channel_post || u.edited_message || u.my_chat_member
|| (u.callback_query && u.callback_query.message);
if (msg && msg.chat && msg.chat.id) { chat = String(msg.chat.id); break; }
}
if (!chat) return { error: '找不到對話:這個 token 是 ' + tgWho(token) + ',但它收到 '
+ list.length + ' 筆訊息。請在 Telegram 搜尋這個帳號本人、傳一句話給它(不是傳給 @BotFather),'
+ '再按一次連線測試。訊息超過 24 小時也會過期。' };
pr.setProperty('TG_CHAT', chat);
}
var sent = tgSend('✅ 連線成功,之後的提醒會送到這裡。');
return sent ? { ok: true, chat: chat, cfg: tgCfg() } : { error: '設定已存,但測試訊息送不出去' };
}
function tgZones() {
return { watch: Number(prop('CFG_zoneWatch', '10')), crash: Number(prop('CFG_zoneCrash', '20')) };
}
function zoneIdx(dropPct) {
var z = tgZones();
if (dropPct <= -z.crash) return 2;
if (dropPct <= -z.watch) return 1;
return 0;
}
var ZONE_NAME = ['正常區', '觀察區', '股災區'];
// 0050 的盤中價,依序試三個來源,只給盤中警示判斷用:
// 1. GOOGLEFINANCE("TPE:0050") 真正的盤中價(約延遲 20 分)。2026-10 起 Google
// 的公式後端查不到 0050,但留著這一階——哪天修回來就自動恢復,不用再改程式。
// 2. Yahoo 的盤中報價,同樣是真實的 0050 價格。
// 3. 006208 推估。它追蹤同一個臺灣50指數,用它的當日漲跌幅套在 0050 的前一日
// 官方收盤上。盤中追蹤差異通常在 0.2% 內,對 10%/20% 的門檻夠用。
// 4. 證交所官方日收盤。盤中拿到的是前一日收盤,不夠即時,但有總比沒有好。
// 推估值只用來判斷要不要發警示,絕不寫進市值、Beta 或再平衡建議。
function px0050Intraday(lastClose) {
var p = googleFinancePrices(['0050', '006208'], 'TPE:', true);
var direct = p['0050'] && p['0050'].price;
if (direct > 0) return { price: direct, src: '' };
var y = yahooTwQuote('0050');
if (y) return { price: y.price, src: '' };
var proxy = p['006208'];
var pct = proxy ? proxy.changepct : null;
if (proxy && proxy.price > 0 && lastClose > 0 && pct !== null && !isNaN(pct)) {
return { price: lastClose * (1 + pct / 100), src: '由 006208 推估' };
}
var tw = twseLastClose('0050');
// 證交所回的是民國日期(115/10/12),只留月日跟其他訊息一致
return tw ? { price: tw.price, src: '官方收盤 ' + tw.date.split('/').slice(1).join('/') } : null;
}
// 0050 的區間高點,直接讀已經抓好的歷史(含分割還原)
function level0050High(months) {
var sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HIST_SHEET);
if (!sh || sh.getLastRow() < 2) return null;
var vals = sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues();
var cut = new Date();
cut.setMonth(cut.getMonth() - (months || 60));
var cutIso = Utilities.formatDate(cut, SNAP_TZ, 'yyyy-MM-dd');
var high = 0, highDate = '', last = 0, lastDate = '';
for (var i = 0; i < vals.length; i++) {
var d = toIso(vals[i][0]);
if (!d || d < cutIso) continue;
var c = adjust(parseFloat(vals[i][1]), d);
if (!(c > 0)) continue;
if (c > high) { high = c; highDate = d; }
if (d >= lastDate) { last = c; lastDate = d; }
}
return high > 0 ? { high: high, highDate: highDate, last: last, lastDate: lastDate } : null;
}
function money(n) {
var v = Number(n) || 0;
return (Math.round(v / 1000) / 10).toFixed(1) + ' 萬';
}
// 一天只回報一次錯誤,避免每 30 分鐘洗版
function tgError(where, err) {
var key = 'TG_ERR_' + Utilities.formatDate(new Date(), SNAP_TZ, 'yyyy-MM-dd');
if (prop(key, '') === where) return;
props().setProperty(key, where);
tgSend('❌ ' + where + '\n' + String(err).slice(0, 300));
}
// 盤中:週一到五 09:00–13:30,每 30 分鐘一次
function intradayTrigger() {
var now = new Date();
var dow = Number(Utilities.formatDate(now, SNAP_TZ, 'u'));
if (dow >= 6) return;
var hm = Utilities.formatDate(now, SNAP_TZ, 'HH:mm');
if (hm < '09:00' || hm > '13:30') return;
var c = tgCfg();
if (!c.token || !c.chat || !c.intraday) return;
try {
var lv = level0050High(60);
if (!lv) return; // 還沒抓過 0050 歷史
var est = px0050Intraday(lv.last);
if (!est || !(est.price > 0)) return;
var px = est.price;
var dd = (px - lv.high) / lv.high * 100;
var z = zoneIdx(dd);
var last = Number(prop('TG_ZONE', '0'));
if (z > last) {
props().setProperty('TG_ZONE', String(z));
tgSend((z === 2 ? '🔴 ' : '⚠️ ') + '盤中 ' + hm + '\n'
+ '0050 ' + px.toFixed(2) + (est.src ? '(' + est.src + ')' : '') + ',距高點 ' + dd.toFixed(2) + '%\n'
+ '進入' + ZONE_NAME[z] + '(高點 ' + lv.high.toFixed(2) + ' @ ' + lv.highDate + ')');
}
} catch (err) {
tgError('盤中檢查失敗', err);
}
}
function setIntradayTrigger(on) {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'intradayTrigger') ScriptApp.deleteTrigger(t);
});
if (on) ScriptApp.newTrigger('intradayTrigger').timeBased().everyMinutes(30).create();
}
// 收盤後:校正水位、檢查 Beta、發當日摘要
function dailyReport(snap) {
var c = tgCfg();
if (!c.token || !c.chat) return;
var lines = ['📊 ' + Utilities.formatDate(new Date(), SNAP_TZ, 'MM/dd') + ' 收盤'];
var tw = snap.data['台股'];
if (tw) {
var cashPct = tw.total > 0 ? Math.round(tw.cash / tw.total * 100) : 0;
lines.push('台股 ' + money(tw.total) + '(現金 ' + money(tw.cash) + '・' + cashPct + '%)');
}
// Beta 與容許區間
var design = Number(prop('CFG_designBeta', '0'));
var dev = Number(prop('CFG_deviationPct', '10'));
var betaLine = '', betaOut = '';
if (tw && design > 0 && tw.beta) {
var lower = design * (1 - dev / 100), upper = design * (1 + dev / 100);
betaOut = tw.beta > upper ? 'high' : (tw.beta < lower ? 'low' : '');
betaLine = 'Beta ' + tw.beta.toFixed(2) + '・容許 ' + lower.toFixed(2) + '–' + upper.toFixed(2)
+ (betaOut ? (betaOut === 'high' ? ' ⚠️ 超出上界' : ' ⚠️ 低於下界') : ' ✅');
lines.push(betaLine);
}
// 0050 水位(官方收盤價,校正盤中的推估)
var lv = level0050High(60);
if (lv && lv.last > 0) {
var dd = (lv.last - lv.high) / lv.high * 100;
var z = zoneIdx(dd);
props().setProperty('TG_ZONE', String(z)); // 回到正常區時也會跟著重設
lines.push('0050 距高點 ' + dd.toFixed(2) + '%・' + ZONE_NAME[z]);
}
var us = snap.data['美股'];
if (us) {
lines.push('美股 $' + Math.round(us.total).toLocaleString()
+ (snap.fx ? '(≈ ' + money(us.total * snap.fx) + ')' : ''));
}
if (c.daily) tgSend(lines.join('\n'));
// Beta 超標:跨越時通知,之後每 7 天再提醒一次
if (c.beta && betaOut) {
var lastSide = prop('TG_BETA_SIDE', ''), lastAt = Number(prop('TG_BETA_AT', '0'));
var days = lastAt ? (Date.now() - lastAt) / 86400000 : 999;
if (lastSide !== betaOut || days >= 7) {
props().setProperty('TG_BETA_SIDE', betaOut);
props().setProperty('TG_BETA_AT', String(Date.now()));
if (!c.daily) tgSend('⚠️ 再平衡提醒\n' + betaLine);
}
} else if (!betaOut) {
props().deleteProperty('TG_BETA_SIDE');
props().deleteProperty('TG_BETA_AT');
}
}
// ───── 指數歷史(報酬指數含息,用來跟自己的績效對照) ─────
var IDX_SHEET = '指數歷史';
var IDX_TR = '發行量加權股價報酬指數';
var IDX_T50 = '臺灣50報酬指數';
function indexSheet() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(IDX_SHEET);
if (!sh) {
sh = ss.insertSheet(IDX_SHEET);
sh.getRange(1, 1, 1, 3).setValues([['日期', '加權報酬指數', '臺灣50報酬指數']]).setFontWeight('bold');
sh.setFrozenRows(1);
}
sh.getRange('A:A').setNumberFormat('@');
return sh;
}
// 某一天的收盤報酬指數;非交易日回傳 null
function fetchIndexDay(ymd) {
var url = 'https://www.twse.com.tw/rwd/zh/afterTrading/MI_INDEX?date=' + ymd + '&type=IND&response=json';
var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
var data = JSON.parse(res.getContentText());
if (!data || data.stat !== 'OK' || !data.tables) return null;
var out = { tr: '', t50: '' };
for (var i = 0; i < data.tables.length; i++) {
var rows = data.tables[i].data || [];
for (var j = 0; j < rows.length; j++) {
var name = String(rows[j][0]).trim();
if (name === IDX_TR) out.tr = num(rows[j][1]);
else if (name === IDX_T50) out.t50 = num(rows[j][1]);
}
}
return (out.tr || out.t50) ? out : null;
}
function num(v) {
var n = parseFloat(String(v).replace(/,/g, ''));
return isNaN(n) ? '' : n;
}
// 寫入(或覆蓋)某一天
function recordIndexDay(date) {
var ymd = Utilities.formatDate(date, SNAP_TZ, 'yyyyMMdd');
var iso = Utilities.formatDate(date, SNAP_TZ, 'yyyy-MM-dd');
var v = fetchIndexDay(ymd);
if (!v) return false;
var sh = indexSheet();
var last = sh.getLastRow();
var seen = last > 1 ? sh.getRange(2, 1, last - 1, 1).getValues() : [];
for (var i = 0; i < seen.length; i++) {
if (toIso(seen[i][0]) === iso) {
sh.getRange(i + 2, 1, 1, 3).setValues([[iso, v.tr, v.t50]]);
return true;
}
}
sh.appendRow([iso, v.tr, v.t50]);
// appendRow 有時仍會讓試算表把日期字串轉成序號,寫完再釘回文字
sh.getRange(sh.getLastRow(), 1).setNumberFormat('@').setValue(iso);
return true;
}
// 往回補抓。證交所一次只能查一天,所以:
// · 一次最多 80 個交易日(約 1 分鐘),超過的留給下一趟
// · 近 400 天逐日抓,更早的每週只抓週三 —— 水位與長期走勢用週資料就夠,
// 請求數少一半以上
function backfillIndex(days) {
var sh = indexSheet();
var last = sh.getLastRow();
var have = {};
if (last > 1) {
var vals = sh.getRange(2, 1, last - 1, 1).getValues();
for (var i = 0; i < vals.length; i++) have[toIso(vals[i][0])] = true;
}
var added = 0, checked = 0, remaining = 0, oldest = '';
var now = new Date();
for (var d = 1; d <= days; d++) {
var day = new Date(now.getTime() - d * 86400000);
var dow = Number(Utilities.formatDate(day, SNAP_TZ, 'u'));
if (dow >= 6) continue; // 週末沒有收盤資料
if (d > 400 && dow !== 3) continue; // 一年以前只取週三
var iso = Utilities.formatDate(day, SNAP_TZ, 'yyyy-MM-dd');
if (have[iso]) continue;
if (checked >= 80) { remaining++; continue; } // 這次沒抓到的留給下一趟
checked++;
try {
if (recordIndexDay(day)) { added++; oldest = iso; }
} catch (err) { /* 單日失敗就跳過 */ }
Utilities.sleep(120); // 對證交所客氣一點
}
return { ok: true, added: added, remaining: remaining, oldest: oldest };
}
function readIndexHistory() {
var sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(IDX_SHEET);
if (!sh || sh.getLastRow() < 2) return [];
return sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues().map(function (r) {
return { d: toIso(r[0]), tr: Number(r[1]) || null, t50: Number(r[2]) || null };
}).filter(function (r) { return r.d; }).sort(function (a, b) { return a.d < b.d ? -1 : 1; });
}
function readHistory() {
var sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SNAP_SHEET);
if (!sh || sh.getLastRow() < 2) return [];
return sh.getRange(2, 1, sh.getLastRow() - 1, 7).getValues().map(function (r) {
return { d: toIso(r[0]), m: String(r[1]), stock: Number(r[2]) || 0, cash: Number(r[3]) || 0,
total: Number(r[4]) || 0, beta: r[5] === '' ? null : Number(r[5]),
fx: r[6] === '' ? null : Number(r[6]) };
}).filter(function (r) { return r.d; });
}
function round2(n) { return Math.round(Number(n || 0) * 100) / 100; }
同步為單向:網頁 → 試算表。試算表上手動修改不會回傳到網頁,每次同步會覆寫「交易紀錄、配置紀錄、持股現況」三個分頁;「每日資產」分頁只會追加,不會被覆寫。
資料備份
資料存在這台裝置的瀏覽器裡,換裝置、換瀏覽器或清除瀏覽資料都會不見。 重要的話請定期匯出備份,或設定上方的 Google 試算表同步。
說明:調整任一標的的目標比例時,現金目標會自動吸收差額,維持合計 100%。 「建議」以 1 張 = 1,000 股無條件捨去換算,除不盡的部分列為零股供你自行決定是否補足。 「槓桿倍數」依代號自動判斷:結尾為 L 的槓桿型 ETF 視為 2 倍,其餘視為 1 倍,現金為 0,用於計算目前 Beta 值。買入與賣出會自動連動現金部位。 此工具僅為個人資產配置試算,並非投資建議,請自行核對交易、稅費與券商對帳單。