Leveraged ETF Rebalancer

正二再平衡策略

槓桿部位再平衡試算工具 — 設定目標比例,即時算出建議買賣張數與整體曝險(Beta)。

已儲存

目前曝險 Beta

0.00β
中性區間
下跌買入 中性 上漲賣出
這些數字是什麼意思?

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。 設太小會頻繁交易增加成本,設太大則失去控制曝險的意義。

為什麼要再平衡:股票漲的時候,它佔資產的比例自動變大, 曝險跟著升高——等於在高點承擔更多風險。再平衡就是把它拉回設定值。

  • 上漲賣出:Beta 超過上界。股票漲太多、曝險過高,賣一些換回現金。
  • 中性:在容許範圍內,什麼都不用做。
  • 下跌買入:Beta 低於下界。股票跌了、曝險不足,用現金加碼買回來。

簡單說:這是一套機械化的「漲了賣、跌了買」紀律, 靠固定的曝險目標決定何時動作,不用預測行情。

資產總覽

總市值(含現金)
$0
現金水位
0%
$0
總損益
$0
再平衡金額 $0
累計淨投入
$0
年化報酬(XIRR)
上漲賣出
0.00
下跌買入
0.00

策略檢視

壓力測試 大盤下跌時,你的部位會怎麼樣
大盤你的資產帳面損益加碼所需現金
以一次性下跌估算。實際上槓桿 ETF 每日重設, 長期震盪或連續下跌的耗損會比這裡更嚴重,請把這張表當成樂觀情境
市場水位 0050 距離高點的跌幅
正常(-10% 以內) 觀察(-10%~-20%) 股災(-20% 以上)
總市值$0
標的目標比例合計0%
現金目標(自動)0%
報價尚未更新

部位變化

交易與現金紀錄

刪除一筆會反向沖銷,並重算之後所有紀錄
時間項目類別張數價格 總金額已實現損益異動後股數異動後現金

配置紀錄

在「試算」頁按「儲存目前配置」新增快照
時間目標比例設計 Beta偏移 %當下 Beta

跨裝置同步(Firebase)

尚未設定。設定後,資料會在各裝置間自動同步。
設定步驟(點開)
  1. console.firebase.google.com → 新增專案(不用開啟 Google Analytics)
  2. 左側選單「建構」→「Realtime Database」→ 建立資料庫
  3. 位置選 asia-southeast1,安全性規則先選測試模式
  4. 建好後複製最上方的網址(結尾是 firebasedatabase.app),貼到上面欄位
  5. 按「產生新路徑」,會得到一組隨機字串
  6. 切到「規則」分頁,把內容換成下方規則(把 你的路徑 換成剛剛產生的字串),按發布
  7. 按「立即上傳」把目前資料送上去
  8. 另一台裝置打開同一個網址,把資料庫網址資料節點填成一樣,按「立即抓取」
Firebase 安全性規則
{
  "rules": {
    "你的路徑": {
      ".read": true,
      ".write": true
    }
  }
}

資料節點是一組隨機字串,等於你的通行證。規則設定成只有這個節點可以讀寫, 其他路徑一律拒絕,所以別人即使知道資料庫網址,猜不到節點就進不來。 這不是加密保護,請不要把節點字串貼給別人或公開。

Google 試算表同步

尚未設定。設定完成後,每次異動會自動寫入試算表。
設定步驟與 Apps Script 程式碼(點開)
  1. 打開你的「正二再平衡策略」試算表 → 上方選單「擴充功能」→「Apps Script」
  2. 把編輯器裡原本的內容全部刪掉,貼上下方程式碼,按存檔
  3. 右上角「部署」→「新增部署作業」→ 類型選「網頁應用程式」
  4. 「執行身分」選我自己,「誰可以存取」選任何人,按「部署」
  5. 第一次會要求授權,一路允許(出現「未經驗證」時點「進階」→「前往…」)
  6. 複製產生的網址(結尾是 /exec),貼回上面欄位,按「測試連線」
  7. 日後修改程式碼後:要「部署」→「管理部署作業」→ 鉛筆圖示 → 版本選新版本 → 部署, 否則網址仍會執行舊的程式碼
Apps Script 程式碼
var SCRIPT_VERSION = 'v1.9';

function doPost(e) {
  var data = JSON.parse(e.postData.contents);
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  writeSheet(ss, '交易紀錄',
    ['時間','項目','類別','張數','價格','總金額','異動後股數','異動後現金'],
    data.trades || []);

  writeSheet(ss, '配置紀錄',
    ['時間','目標比例','設計Beta','偏移%','當下Beta'],
    data.configs || []);

  writeSheet(ss, '持股現況',
    ['名稱','股數','現價','槓桿','現值','目標比例'],
    data.holdings || []);

  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 : '';

  // 報價中轉:瀏覽器不能直連證交所(CORS),改由這裡代抓
  if (action === 'quote') {
    var ex = (e.parameter.ex || '');
    var url = 'https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch='
            + encodeURIComponent(ex) + '&json=1&delay=0';
    var out;
    try {
      var res = UrlFetchApp.fetch(url, {
        muteHttpExceptions: true,
        headers: {
          'User-Agent': 'Mozilla/5.0',
          'Referer': 'https://mis.twse.com.tw/stock/index.jsp'
        }
      });
      out = res.getContentText();
      JSON.parse(out);            // 確認是合法 JSON,不是錯誤頁
    } catch (err) {
      out = JSON.stringify({ msgArray: [], error: String(err) });
    }
    return ContentService.createTextOutput(out)
      .setMimeType(ContentService.MimeType.JSON);
  }

  // 版本探測:不抓任何外部資料,只回報這份程式碼支援哪些功能
  if (action === 'ping') {
    return ContentService.createTextOutput(JSON.stringify({
      ok: true, version: SCRIPT_VERSION, features: ['quote', 'macro', 'level']
    })).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++) {
        if (idx[i]['指數'] === '發行量加權股價指數') {
          var sign = (idx[i]['漲跌'] === '-') ? -1 : 1;
          out.taiex = {
            close: idx[i]['收盤指數'],
            changePct: sign * parseFloat(idx[i]['漲跌百分比'] || 0)
          };
          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歷史';

// 抓過的月份存進試算表,之後只補缺的月份(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;
  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;
}

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;
}

function writeSheet(ss, name, header, rows) {
  var sh = ss.getSheetByName(name) || ss.insertSheet(name);
  sh.clear();
  sh.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold');
  if (rows.length) {
    sh.getRange(2, 1, rows.length, header.length).setValues(rows);
  }
  sh.setFrozenRows(1);
}

同步為單向:網頁 → 試算表。試算表上手動修改不會回傳到網頁,每次同步會覆寫這三個分頁的內容。

資料備份

資料存在這台裝置的瀏覽器裡,換裝置、換瀏覽器或清除瀏覽資料都會不見。 重要的話請定期匯出備份,或設定上方的 Google 試算表同步。

網頁版本  Apps Script 狀態未檢查

說明:調整任一標的的目標比例時,現金目標會自動吸收差額,維持合計 100%。 「建議」以 1 張 = 1,000 股無條件捨去換算,除不盡的部分列為零股供你自行決定是否補足。 「槓桿倍數」依代號自動判斷:結尾為 L 的槓桿型 ETF 視為 2 倍,其餘視為 1 倍,現金為 0,用於計算目前 Beta 值。買入與賣出會自動連動現金部位。 此工具僅為個人資產配置試算,並非投資建議,請自行核對交易、稅費與券商對帳單。