加權 —
收盤
報價尚未更新 已儲存

曝險 BETA

0.00β
中性區間
下跌買入
—
中性
—
上漲賣出
—
總市值(含現金)
$0
今日變動(股票)
—
現金水位
0%
$0
總損益
$0
再平衡金額 $0
累計淨投入
$0
年化報酬(XIRR)
—
年化報酬(XIRR)
—
上漲賣出
0.00
下跌買入
0.00
持股與占比 白線為目標

股數與成本一律以 Firstrade 匯入為準, 避免手動輸入與匯入紀錄重複計算。買賣請到「紀錄」頁匯入交易明細。

資產走勢每日自動寫入的總市值紀錄

損益變化

部位變化(股數與現金)

財務目標

動態提領試算(推估目標金額)

需要資產 = 年提領 ÷ 提領率。護欄以目前股票市值反推實際提領率: 低於下界代表提領率偏高、該調降支出,高於上界則可調升(±20%,Guyton-Klinger 的護欄概念)。 這是試算,不是投資建議;提領率、通膨、稅費都會改變結果,槓桿 ETF 的波動與順序風險更會讓實際情況偏離試算。

壓力測試 · 自訂跌幅大盤下跌時,你的部位會怎麼樣

−20%
0%−10%−20%−30%−40%−50%
以一次性下跌估算。實際上槓桿 ETF 每日重設, 長期震盪或連續下跌的耗損會比這裡更嚴重,請把這個結果當成樂觀情境。

市場水位0050 距離高點的跌幅

正常(-10% 以內) 觀察(-10%~-20%) 股災(-20% 以上)

總經指標

台指期(TX)開盤前參考,日盤/夜盤各時段結束後更新

美股情緒指標

CNN Fear & Greed · 非官方端點,僅供參考
交易與現金紀錄

匯入 Firstrade 交易明細

Firstrade → 帳戶記錄 → 匯出交易明細
重複的紀錄會自動略過,可以放心重複匯入整份檔案。

點一下紀錄可展開明細。刪除一筆會反向沖銷,並重算之後所有紀錄。配置快照在「試算」頁按「儲存目前配置」新增。

設定

版本

網頁版本  Apps Script 狀態未檢查

Telegram 通知

盤中水位警示
交易日 09:00–13:30 每 30 分檢查 0050 距高點的跌幅,跌進觀察區或股災區時通知,同一區間只通知一次。
再平衡提醒
收盤後檢查 Beta 是否超出容許區間,跨越時通知一次,之後每 7 天再提醒。
每日摘要(含錯誤通知)
每個交易日 14:00 送出市值、現金、Beta 與水位;寫入失敗或出錯也由這裡回報。

先在 Telegram 找 @BotFather 輸入 /newbot 取得 token,貼上後 先對你的機器人傳一句話(否則它無權主動找你),再按「連線測試」。token 會存在 Apps Script, 網頁關著也能通知。盤中警示只看台股;美股數字放在每日摘要裡。

美股財報提醒

到 finnhub.io 免費註冊即可取得金鑰(不用付費方案)。持股表格會多一欄「財報日」,到期天數內會標紅並在試算頁提醒。 ETF 沒有財報,該欄留空。每天自動更新一次,也可以按上面的按鈕手動更新。

美股幣別顯示

只改變美股頁面的金額顯示;紀錄、成本與試算表同步仍以美元儲存,每股價格也維持美元。

跨裝置同步

用 Google 帳號登入即可跨裝置同步,不用再手動填網址或節點。

Google 試算表同步

尚未設定。設定完成後,每次異動會自動寫入試算表。
每日自動寫入
每個交易日 14:00 後(台北時間)把台股、美股的市值、現金與 Beta 追加一列到「每日資產」分頁,網頁沒開也會執行。試算頁的「資產走勢」就是用這份紀錄畫的。
設定步驟與 Apps Script 程式碼(點開)
  1. 打開你的「正二再平衡策略」試算表 → 上方選單「擴充功能」→「Apps Script」
  2. 把編輯器裡原本的內容全部刪掉,貼上下方程式碼,按存檔
  3. 右上角「部署」→「新增部署作業」→ 類型選「網頁應用程式」
  4. 「執行身分」選我自己,「誰可以存取」選任何人,按「部署」
  5. 第一次會要求授權,一路允許(出現「未經驗證」時點「進階」→「前往…」)
  6. 複製產生的網址(結尾是 /exec),貼回上面欄位,按「測試連線」
  7. 日後修改程式碼後:要「部署」→「管理部署作業」→ 鉛筆圖示 → 版本選新版本 → 部署, 否則網址仍會執行舊的程式碼
  8. v3.0 起新增「每日自動寫入」(定時觸發)。更新部署版本時 Google 不會主動要求新權限, 請在編輯器上方的函式選單選 authorize,按「執行」,出現授權畫面時一路允許(只需做一次)
Apps Script 程式碼
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 值。買入與賣出會自動連動現金部位。 此工具僅為個人資產配置試算,並非投資建議,請自行核對交易、稅費與券商對帳單。