iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0

因為我會有多條策略需要監控(每一個策略各獨立一個container),每條策略會定期把最新的監控數據寫到Upstash裡面,然後儀錶板網頁透過讀取Upstash會知道有多少條策略,並把這些數據都秀出來;只要在Notion中遷入儀表板的網址,就能從Notion的某個筆記頁中直接監控所有策略的最新狀況:
https://ithelp.ithome.com.tw/upload/images/20251005/20161802ruLuzhY0tf.jpg

下面寫一個假的python程式模擬把數據推上Upstash:

import requests, json, random
from datetime import datetime, timedelta
import pytz

# ====== Upstash REST 設定(請填入你自己的)======
REST_URL = ""        # 例如:https://us1-rest-sly-xxx.upstash.io
WRITE_TOKEN = ""     # Write Token(千萬不要放到前端)
headers = {"Authorization": f"Bearer {WRITE_TOKEN}"}

# ====== 參數 ======
DAYS = 90
BASE_EQUITY = 100_000.0
BASE_PRICE = 3500.0
tz = pytz.timezone("Asia/Taipei")

def upstash_set(key: str, obj):
    url = f"{REST_URL}/set/{key}"
    res = requests.post(url, data=json.dumps(obj, ensure_ascii=False), headers=headers, timeout=10)
    res.raise_for_status()
    return res.json()

def compute_drawdown(equity_list):
    """回傳最大回撤(負值),e.g. -0.0833"""
    peak = equity_list[0]
    max_dd = 0.0
    for x in equity_list:
        if x > peak: peak = x
        dd = (x / peak) - 1.0
        if dd < max_dd:
            max_dd = dd
    return round(max_dd, 6)

def annualized_return(total_return, days):
    if days <= 0:
        return 0.0
    return round((1.0 + total_return) ** (365.0 / days) - 1.0, 6)

def gen_one_strategy(idx: int, now_tpe: datetime):
    rng = random.Random(20251005 + idx)  # 固定 seed(你可改成 time-based)
    equity = BASE_EQUITY
    price = BASE_PRICE

    series = []
    equities = []

    for i in range(DAYS):
        d = (now_tpe - timedelta(days=DAYS - 1 - i)).date().isoformat()
        # 製造不同策略風格(均值 & 波動不同)
        mu = rng.uniform(0.0001, 0.0012)
        sigma = rng.uniform(0.007, 0.018)
        daily_ret = rng.gauss(mu, sigma)
        equity *= (1.0 + daily_ret)
        equities.append(equity)

        price += rng.uniform(-60, 90)
        if price < 100: price = 100.0

        series.append({
            "date": d,
            "equity": round(equity, 2),
            "eth_close": round(price, 2),
        })

    total_ret = round((equity / equities[0]) - 1.0, 6)
    mdd = compute_drawdown(equities)
    ar = annualized_return(total_ret, DAYS)

    started_at = (now_tpe - timedelta(days=DAYS)).replace(second=0, microsecond=0).isoformat(timespec="minutes")
    updated_at = now_tpe.replace(second=0, microsecond=0).isoformat(timespec="minutes")

    board = {
        "id": f"demo-{idx}",
        "name": f"DEMO 策略 {idx}",
        "symbol": "ETH/USDC",
        "run_mode": "backtest",
        "venue": "binance.mainnet",
        "return_total": total_ret,
        "annualized_return": ar,
        "max_drawdown": mdd,
        "started_at": started_at,
        "updated_at": updated_at,
    }
    return board, series

def main():
    now_tpe = datetime.now(tz)
    # 隨機 N(2~4)
    N = random.randint(2, 4)

    strategies_index = []
    for i in range(1, N + 1):
        board, series = gen_one_strategy(i, now_tpe)

        # 寫入 board 與 series
        print(f"寫入 board:{board['id']}")
        upstash_set(f"board:{board['id']}", board)

        print(f"寫入 series:{board['id']}:equity({len(series)} 筆)")
        upstash_set(f"series:{board['id']}:equity", series)

        # 索引列(列表頁面只需用到的欄位)
        strategies_index.append({
            "id": board["id"],
            "title": board["name"],
            "symbol": board["symbol"],
            "return_total": board["return_total"],
            "max_drawdown": board["max_drawdown"],
            "updated_at": board["updated_at"],
        })

    print("寫入 strategies:list")
    upstash_set("strategies:list", strategies_index)

    print(f"✔ 完成。共寫入 {N} 條策略。")

if __name__ == "__main__":
    main()

接下來更新github的靜態網頁,要讀取數據庫,每一個Row都是一條策略,要從數據庫讀出有多少條策略已經註冊進資料庫,然後把每條策略都畫出來:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8" />
  <title>交易策略儀表板(多策略)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <style>
    :root { --w: 1100px; }
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans TC", Arial, "Helvetica Neue", sans-serif; margin: 20px; color: #222; }
    h1 { font-size: 22px; margin: 16px 0; }
    .panel { max-width: var(--w); margin: 0 auto; }
    .error { color: #c00; margin: 10px 0; }
    .muted { color: #666; }

    table { width: 100%; border-collapse: collapse; margin: 12px 0 20px; }
    th, td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
    th { background: #fafafa; font-weight: 600; }
    tr:hover { background: #f8fbff; }
    .num { text-align: right; font-variant-numeric: tabular-nums; }
    .pos { color: #0b7d34; }
    .neg { color: #b20000; }
    .btn {
      padding: 6px 12px; border: 1px solid #ddd; background: #fff; border-radius: 10px; cursor: pointer;
      transition: box-shadow .15s ease, transform .03s ease;
    }
    .btn:hover { box-shadow: 0 2px 10px rgba(0,0,0,.06); }
    .btn:active { transform: translateY(1px); }
    .toolbar { display: flex; gap: 8px; align-items: center; margin: 8px 0 16px; }
    #chart-wrap { max-width: var(--w); margin: 16px auto 0; }
    #chart-card { border: 1px solid #eee; border-radius: 16px; padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.04); }
    #chart-title { margin: 0 0 8px; font-size: 18px; }
    #chart-sub { margin: 0 0 12px; color: #666; font-size: 14px; }
    #chart-container { position: relative; height: 420px; }
  </style>
</head>
<body>
  <div class="panel">
    <h1>交易策略儀表板(多策略)</h1>
    <div id="error" class="error"></div>

    <div class="toolbar">
      <span id="count" class="muted"></span>
    </div>

    <table id="tbl">
      <thead>
        <tr>
          <th style="width:34%;">title</th>
          <th style="width:16%;">symbol</th>
          <th class="num" style="width:16%;">總收益率</th>
          <th class="num" style="width:16%;">最大回撤</th>
          <th style="width:18%;">按鈕</th>
        </tr>
      </thead>
      <tbody id="tbody"></tbody>
    </table>

    <div id="chart-wrap" style="display:none;">
      <div id="chart-card">
        <h3 id="chart-title"></h3>
        <p id="chart-sub" class="muted"></p>
        <div id="chart-container">
          <canvas id="myChart"></canvas>
        </div>
      </div>
    </div>
  </div>

  <script>
    // ====== Upstash 讀取設定(只放 Read-Only Token)======
    const REST_URL = "";   // 你的 Upstash REST_URL
    const READ_TOKEN = ""; // Read-Only Token
    const headers = { "Authorization": `Bearer ${READ_TOKEN}` };

    // ====== Utils ======
    const $ = (sel) => document.querySelector(sel);
    const fmtPct = (x) => {
      const s = (x * 100).toFixed(2) + "%";
      return x >= 0 ? `<span class="pos">${s}</span>` : `<span class="neg">${s}</span>`;
    };
    const fmtDrawdown = (x) => `<span class="${x <= 0 ? 'neg' : 'pos'}">${(x * 100).toFixed(2)}%</span>`;

    async function upstashGet(key) {
      const res = await fetch(`${REST_URL}/get/${key}`, { headers });
      if (!res.ok) throw new Error(`GET ${key} failed: ${res.status}`);
      const data = await res.json();
      try { return JSON.parse(data.result); } catch { return data.result; }
    }

    // ====== 狀態 ======
    let strategies = [];      // N 策略
    let chart;                // Chart.js 實例
    const cache = new Map();  // 快取 board/series

    function renderTable() {
      const tbody = $("#tbody");
      tbody.innerHTML = "";

      for (const s of strategies) {
        const tr = document.createElement("tr");
        tr.innerHTML = `
          <td>${s.title}</td>
          <td>${s.symbol}</td>
          <td class="num">${fmtPct(s.return_total)}</td>
          <td class="num">${fmtDrawdown(s.max_drawdown)}</td>
          <td><button class="btn" data-id="${s.id}">查看圖表</button></td>
        `;
        tbody.appendChild(tr);
      }

      // 綁定按鈕
      tbody.querySelectorAll("button[data-id]").forEach(btn => {
        btn.addEventListener("click", () => showChart(btn.dataset.id));
      });

      // 顯示列數(N),含表頭視覺上為 N+1 行
      $("#count").textContent = `共 ${strategies.length} 策略(表格行數:${strategies.length + 1},含表頭)`;
    }

    async function showChart(id) {
      try {
        $("#chart-wrap").style.display = "block";

        // 讀 board + series(快取)
        let board = cache.get(`board:${id}`);
        if (!board) {
          board = await upstashGet(`board:${id}`);
          cache.set(`board:${id}`, board);
        }
        let series = cache.get(`series:${id}:equity`);
        if (!series) {
          series = await upstashGet(`series:${id}:equity`);
          cache.set(`series:${id}:equity`, series);
        }

        // 標題與摘要
        $("#chart-title").textContent = `${board.name}(${board.symbol})`;
        $("#chart-sub").textContent =
          `總收益率:${(board.return_total * 100).toFixed(2)}% | 年化:${(board.annualized_return * 100).toFixed(2)}% | 最大回撤:${(board.max_drawdown * 100).toFixed(2)}% | 期間:${(board.started_at ?? "").slice(0,10)} ~ ${(board.updated_at ?? "").slice(0,10)}`;

        // 準備資料
        const labels = series.map(d => d.date);
        const equity = series.map(d => d.equity);
        const price = series.map(d => d.eth_close);

        // 畫圖或更新
        const ctx = document.getElementById("myChart").getContext("2d");
        if (!chart) {
          chart = new Chart(ctx, {
            type: "line",
            data: {
              labels,
              datasets: [
                { label: "Equity", data: equity, borderColor: "blue", borderWidth: 2, tension: .1, yAxisID: "y" },
                { label: "ETH Close", data: price, borderColor: "orange", borderWidth: 2, tension: .1, yAxisID: "y1" }
              ]
            },
            options: {
              maintainAspectRatio: false,
              responsive: true,
              interaction: { mode: "index", intersect: false },
              stacked: false,
              plugins: { legend: { display: true } },
              scales: {
                y:  { type: "linear", position: "left",  title: { display: true, text: "Equity" } },
                y1: { type: "linear", position: "right", title: { display: true, text: "ETH Price" }, grid: { drawOnChartArea: false } }
              }
            }
          });
        } else {
          chart.data.labels = labels;
          chart.data.datasets[0].data = equity;
          chart.data.datasets[1].data = price;
          chart.update();
        }
      } catch (err) {
        $("#error").textContent = "❌ 載入錯誤:" + (err?.message || err);
      }
    }

    (async function main() {
      try {
        // 讀取清單(N 策略)
        strategies = await upstashGet("strategies:list");
        if (!Array.isArray(strategies)) throw new Error("strategies:list 格式錯誤");
        renderTable();

        // 可選:預設先秀第一列的圖
        if (strategies.length) showChart(strategies[0].id);
      } catch (err) {
        $("#error").textContent = "❌ 初始化失敗:" + (err?.message || err);
      }
    })();
  </script>
</body>
</html>

在Notion裡面遷入embed,在embed輸入儀表板的網址:
https://ithelp.ithome.com.tw/upload/images/20251005/20161802rYTDoD1EvR.jpg
儀表板中輸入網址:
https://ithelp.ithome.com.tw/upload/images/20251005/20161802xbeGpBFj5V.jpg


上一篇
Day 24 - Notion Dashboard
下一篇
Day 26 - 使用 Notion 做為策略儀表板的總結
系列文
從零開始:AWS 部署 Python 自動交易程式與交易監測 Dashboard 實戰筆記28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言