株価分析ツール「QWANT VIEW」

【更新履歴】

 ・2026/2/19 バージョン1.0公開。


画像
メイン画面

※このツールをローカル環境で実行するには、
 自分でサーバーを立てるか、
 「Local Test Browser」が必要です。

・Pythonでサーバーを立てて使う場合は、
 server.pyをダブルクリックしてから、
 webブラウザのアドレスバーに
 http://localhost:8765/と入力して下さい。

・BOOTHでは金融関連の商品が出品禁止になっているため、
 有料エリアを設定することにしました。m(u_u)m


主な機能

データ取得

  • Yahoo Finance API経由でWebから自動取得(例: 7203.T、AAPL、BTC-USD)

  • CSVファイルのドロップ/選択でも読み込み可能(Date/Open/High/Low/Close/Volume列を自動認識)

集計範囲の設定

  • 開始・終了日の指定

  • 「最新N日間」スライダーで古いデータを除外

  • 日足/週足/月足の切り替え

5つの分析タブ


概要
KPI一覧
・価格推移
・日次リターン分布
・トレンドサマリー(強気/弱気バッジ)

価格チャート
終値
・短期/長期移動平均
・出来高

ボラティリティ
ローリングボラ(年率換算)
・高/中/低ボラ期の分類
・ボラ水準別リターン比較

分布分析
日次リターンヒストグラム
・自己相関(ACF)
・月別平均リターン

確率マトリクス
基準価格の±N%出現確率
・翌日リターン帯別の実績確率テーブル
・KDE(正規分布との比較)

使い方:
・シンボル入力後「WEBから取得」ボタン、
 またはCSVをドロップして「分析実行」。

・範囲を変えてから「範囲を適用」で再分析されます。


シンボルのStooq自動変換

 7203.T → 7203.jp
 AAPL → aapl.us
 BTC-US → Dbtc.v
 ^N225 → ^nkx
 ^GSPC → ^spx


・取扱説明書はこちら。↓


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>株価傾向分析ツール</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #0a0c10;
    --bg2: #111418;
    --bg3: #1a1f28;
    --border: #242c3a;
    --accent: #00e5ff;
    --accent2: #ff6b35;
    --accent3: #7fff6b;
    --text: #c8d8e8;
    --text2: #6a8090;
    --up: #00e676;
    --down: #ff1744;
    --warn: #ffea00;
    --panel: rgba(26,31,40,0.9);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 13px;
    min-height: 100vh;
    overflow-x: hidden;
  }
  body::before {
    content: '';
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    background: 
      radial-gradient(ellipse at 20% 20%, rgba(0,229,255,0.04) 0%, transparent 60%),
      radial-gradient(ellipse at 80% 80%, rgba(255,107,53,0.04) 0%, transparent 60%);
    pointer-events: none;
    z-index: 0;
  }

  header {
    padding: 16px 24px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    gap: 16px;
    background: rgba(10,12,16,0.95);
    position: sticky; top: 0; z-index: 100;
    backdrop-filter: blur(10px);
  }
  .logo {
    font-family: 'Space Mono', monospace;
    font-size: 18px;
    color: var(--accent);
    letter-spacing: 2px;
    white-space: nowrap;
  }
  .logo span { color: var(--accent2); }
  .status-bar {
    flex: 1;
    display: flex;
    gap: 20px;
    align-items: center;
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    color: var(--text2);
    overflow: hidden;
  }
  .stat-pill {
    display: flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
  }
  .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
  .dot.warn { background: var(--warn); }
  .dot.ok { background: var(--up); }

  .main-layout {
    display: grid;
    grid-template-columns: 280px 1fr;
    grid-template-rows: auto 1fr;
    gap: 0;
    min-height: calc(100vh - 56px);
    position: relative;
    z-index: 1;
  }

  .sidebar {
    grid-row: 1 / 3;
    border-right: 1px solid var(--border);
    background: var(--bg2);
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 16px;
    overflow-y: auto;
  }

  .content-area {
    display: flex;
    flex-direction: column;
    gap: 0;
    overflow: hidden;
  }

  .section-title {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    letter-spacing: 2px;
    color: var(--text2);
    text-transform: uppercase;
    margin-bottom: 8px;
    padding-bottom: 6px;
    border-bottom: 1px solid var(--border);
  }

  .panel {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 14px;
  }

  label { display: block; color: var(--text2); font-size: 11px; margin-bottom: 4px; }
  input, select {
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 7px 10px;
    border-radius: 4px;
    font-size: 12px;
    font-family: inherit;
    outline: none;
    transition: border-color 0.2s;
  }
  input:focus, select:focus { border-color: var(--accent); }
  input[type="range"] {
    padding: 4px 0;
    border: none;
    background: transparent;
    accent-color: var(--accent);
  }

  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 12px;
    font-family: 'Space Mono', monospace;
    cursor: pointer;
    border: none;
    transition: all 0.2s;
    width: 100%;
  }
  .btn-primary {
    background: var(--accent);
    color: #000;
    font-weight: 700;
  }
  .btn-primary:hover { background: #33eeff; box-shadow: 0 0 20px rgba(0,229,255,0.3); }
  .btn-secondary {
    background: transparent;
    color: var(--accent2);
    border: 1px solid var(--accent2);
  }
  .btn-secondary:hover { background: rgba(255,107,53,0.1); }
  .btn-sm { padding: 5px 10px; width: auto; font-size: 11px; }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }

  .file-drop {
    border: 2px dashed var(--border);
    border-radius: 6px;
    padding: 20px;
    text-align: center;
    cursor: pointer;
    transition: all 0.2s;
    color: var(--text2);
    font-size: 12px;
  }
  .file-drop:hover, .file-drop.drag { border-color: var(--accent); color: var(--accent); }
  .file-drop input { display: none; }

  .tab-bar {
    display: flex;
    border-bottom: 1px solid var(--border);
    background: var(--bg2);
    padding: 0 16px;
  }
  .tab {
    padding: 12px 20px;
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    letter-spacing: 1px;
    cursor: pointer;
    color: var(--text2);
    border-bottom: 2px solid transparent;
    transition: all 0.2s;
    white-space: nowrap;
  }
  .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
  .tab:hover { color: var(--text); }

  .tab-content {
    display: none;
    padding: 16px;
    flex: 1;
    overflow-y: auto;
  }
  .tab-content.active { display: block; }

  .chart-wrap {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 16px;
    margin-bottom: 16px;
    position: relative;
  }
  .chart-title {
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    letter-spacing: 1px;
    color: var(--text2);
    margin-bottom: 12px;
  }
  .chart-wrap canvas { max-height: 260px; }

  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }

  .kpi-card {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 14px;
  }
  .kpi-label { font-size: 10px; color: var(--text2); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
  .kpi-value { font-family: 'Space Mono', monospace; font-size: 22px; font-weight: 700; }
  .kpi-value.up { color: var(--up); }
  .kpi-value.down { color: var(--down); }
  .kpi-value.neutral { color: var(--accent); }
  .kpi-sub { font-size: 10px; color: var(--text2); margin-top: 4px; }

  .trend-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 3px 10px;
    border-radius: 12px;
    font-size: 11px;
    font-family: 'Space Mono', monospace;
    font-weight: 700;
  }
  .trend-bull { background: rgba(0,230,118,0.15); color: var(--up); border: 1px solid var(--up); }
  .trend-bear { background: rgba(255,23,68,0.15); color: var(--down); border: 1px solid var(--down); }
  .trend-neutral { background: rgba(255,234,0,0.1); color: var(--warn); border: 1px solid var(--warn); }

  .prob-bar-wrap { margin-bottom: 8px; }
  .prob-bar-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--text2); margin-bottom: 3px; }
  .prob-bar-bg { background: var(--bg); border-radius: 2px; height: 8px; overflow: hidden; }
  .prob-bar-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }

  .vol-indicator {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    background: var(--bg);
    border-radius: 4px;
    margin-bottom: 8px;
    border-left: 3px solid var(--accent);
  }
  .vol-indicator.high { border-left-color: var(--down); }
  .vol-indicator.low { border-left-color: var(--up); }
  .vol-indicator.med { border-left-color: var(--warn); }

  .dist-table { width: 100%; border-collapse: collapse; font-size: 12px; }
  .dist-table th {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    letter-spacing: 1px;
    color: var(--text2);
    text-align: left;
    padding: 8px;
    border-bottom: 1px solid var(--border);
    white-space: nowrap;
  }
  .dist-table td {
    padding: 7px 8px;
    border-bottom: 1px solid rgba(36,44,58,0.5);
    font-family: 'Space Mono', monospace;
    font-size: 11px;
  }
  .dist-table tr:hover td { background: rgba(0,229,255,0.04); }

  #loading {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(10,12,16,0.8);
    z-index: 999;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 16px;
    backdrop-filter: blur(4px);
  }
  #loading.show { display: flex; }
  .spinner {
    width: 40px; height: 40px;
    border: 3px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  #log {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    color: var(--text2);
    max-height: 80px;
    overflow-y: auto;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 8px;
  }
  #log div { margin-bottom: 2px; }
  #log .ok { color: var(--up); }
  #log .err { color: var(--down); }
  #log .info { color: var(--accent); }

  .no-data {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 200px;
    color: var(--text2);
    font-size: 12px;
    gap: 8px;
  }
  .no-data-icon { font-size: 36px; opacity: 0.3; }

  .range-row { display: flex; gap: 8px; align-items: flex-end; }
  .range-row > * { flex: 1; }

  @media (max-width: 768px) {
    .main-layout { grid-template-columns: 1fr; }
    .sidebar { grid-row: auto; border-right: none; border-bottom: 1px solid var(--border); }
    .grid-2, .grid-3 { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>

<div id="loading"><div class="spinner"></div><div style="font-family:'Space Mono',monospace;font-size:12px;color:var(--accent)">データ取得中...</div></div>

<header>
  <div class="logo">QUANT<span>VIEW</span></div>
  <div class="status-bar">
    <div class="stat-pill"><div class="dot" id="dot-data"></div><span id="stat-symbol">─ データ未読み込み</span></div>
    <div class="stat-pill"><div class="dot ok"></div><span id="stat-range">範囲: ─</span></div>
    <div class="stat-pill"><div class="dot warn" id="dot-vol"></div><span id="stat-vol">ボラ: ─</span></div>
  </div>
</header>

<div class="main-layout">
  <!-- SIDEBAR -->
  <div class="sidebar">
    <div>
      <div class="section-title">データソース</div>
      <div class="panel" style="margin-bottom:10px">
        <label>銘柄シンボル (Stooq / Yahoo Finance)</label>
        <input type="text" id="symbolInput" placeholder="例: 7203.T, AAPL, BTC-USD" value="7203.T">
        <div style="font-size:10px;color:var(--text2);margin-top:3px;line-height:1.7">
          日本株: <span style="color:var(--accent)">7203.T</span> &nbsp;
          米株: <span style="color:var(--accent)">AAPL</span> &nbsp;
          BTC: <span style="color:var(--accent)">BTC-USD</span><br>
          日経225: <span style="color:var(--accent)">^N225</span> &nbsp;
          S&amp;P500: <span style="color:var(--accent)">^GSPC</span>
        </div>
        <div style="margin-top:6px; display:flex; gap:6px">
          <select id="periodSelect" style="flex:1">
            <option value="1mo">1ヶ月</option>
            <option value="3mo">3ヶ月</option>
            <option value="6mo">6ヶ月</option>
            <option value="1y" selected>1年</option>
            <option value="2y">2年</option>
            <option value="5y">5年</option>
          </select>
          <select id="intervalSelect" style="flex:1">
            <option value="1d" selected>日足</option>
            <option value="1wk">週足</option>
            <option value="1mo">月足</option>
          </select>
        </div>
        <button class="btn btn-primary" style="margin-top:8px" onclick="fetchFromAPI()">▶ WEBから取得 (Stooq優先)</button>
      </div>

      <div class="file-drop panel" id="dropZone" onclick="document.getElementById('csvFile').click()" ondragover="ev(event)" ondrop="dropFile(event)">
        <input type="file" id="csvFile" accept=".csv" onchange="loadCSV(this)">
        <div style="font-size:20px;margin-bottom:6px">📂</div>
        <div>CSVファイルをドロップ</div>
        <div style="font-size:10px;margin-top:4px;color:var(--text2)">または クリックして選択</div>
        <div style="font-size:10px;margin-top:6px;color:var(--text2);line-height:1.6">
          CSV入手先:<br>
          ・<a href="https://stooq.com" target="_blank" style="color:var(--accent)">stooq.com</a> (日本株/米株)<br>
          ・Yahoo Finance → 履歴データ
        </div>
      </div>
    </div>

    <div>
      <div class="section-title">集計範囲</div>
      <div class="panel">
        <div class="range-row" style="margin-bottom:8px">
          <div><label>開始日</label><input type="date" id="rangeStart"></div>
          <div><label>終了日</label><input type="date" id="rangeEnd"></div>
        </div>
        <label>最新 N日間のみ</label>
        <div style="display:flex;gap:8px;align-items:center">
          <input type="range" id="nDays" min="10" max="500" value="252" oninput="document.getElementById('nDaysVal').textContent=this.value">
          <span id="nDaysVal" style="font-family:'Space Mono',monospace;font-size:12px;color:var(--accent);min-width:36px">252</span>
        </div>
        <button class="btn btn-secondary" style="margin-top:8px" onclick="applyRange()">範囲を適用</button>
      </div>
    </div>

    <div>
      <div class="section-title">分析設定</div>
      <div class="panel">
        <label>移動平均 (短期)</label>
        <input type="number" id="maShort" value="5" min="2" max="50">
        <label style="margin-top:8px">移動平均 (長期)</label>
        <input type="number" id="maLong" value="25" min="5" max="200">
        <label style="margin-top:8px">ボラティリティ窓 (日)</label>
        <input type="number" id="volWindow" value="20" min="5" max="60">
        <label style="margin-top:8px">価格ビン幅 (%)</label>
        <input type="number" id="binSize" value="1" min="0.1" max="5" step="0.1">
        <button class="btn btn-primary" style="margin-top:10px" onclick="runAnalysis()">▶ 分析実行</button>
      </div>
    </div>

    <div>
      <div class="section-title">ログ</div>
      <div id="log"></div>
    </div>
  </div>

  <!-- CONTENT -->
  <div class="content-area">
    <div class="tab-bar">
      <div class="tab active" onclick="switchTab('overview')">概要</div>
      <div class="tab" onclick="switchTab('price')">価格チャート</div>
      <div class="tab" onclick="switchTab('volatility')">ボラティリティ</div>
      <div class="tab" onclick="switchTab('distribution')">分布分析</div>
      <div class="tab" onclick="switchTab('probability')">確率マトリクス</div>
    </div>

    <!-- OVERVIEW -->
    <div class="tab-content active" id="tab-overview">
      <div id="kpi-area" class="grid-3" style="margin-bottom:16px"></div>
      <div class="grid-2">
        <div class="chart-wrap">
          <div class="chart-title">価格推移 (概要)</div>
          <canvas id="chart-overview-price"></canvas>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">日次リターン分布</div>
          <canvas id="chart-overview-dist"></canvas>
        </div>
      </div>
      <div id="trend-summary" class="panel" style="margin-top:0"></div>
    </div>

    <!-- PRICE -->
    <div class="tab-content" id="tab-price">
      <div class="chart-wrap" style="margin-bottom:16px">
        <div class="chart-title">価格チャート + 移動平均</div>
        <canvas id="chart-price" style="max-height:320px"></canvas>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">出来高</div>
        <canvas id="chart-volume"></canvas>
      </div>
    </div>

    <!-- VOLATILITY -->
    <div class="tab-content" id="tab-volatility">
      <div class="chart-wrap" style="margin-bottom:16px">
        <div class="chart-title">ローリングボラティリティ (年率換算 %)</div>
        <canvas id="chart-vol"></canvas>
      </div>
      <div class="grid-2">
        <div>
          <div class="section-title" style="margin-bottom:10px">ボラティリティ分類</div>
          <div id="vol-classification"></div>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">ボラ水準別 翌日リターン分布</div>
          <canvas id="chart-vol-return"></canvas>
        </div>
      </div>
    </div>

    <!-- DISTRIBUTION -->
    <div class="tab-content" id="tab-distribution">
      <div class="grid-2" style="margin-bottom:16px">
        <div class="chart-wrap">
          <div class="chart-title">価格変化率ヒストグラム (日次%)</div>
          <canvas id="chart-hist"></canvas>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">自己相関 (ACF)</div>
          <canvas id="chart-acf"></canvas>
        </div>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">時間的分布 (月別 平均リターン)</div>
        <canvas id="chart-monthly"></canvas>
      </div>
    </div>

    <!-- PROBABILITY -->
    <div class="tab-content" id="tab-probability">
      <div class="panel" style="margin-bottom:16px">
        <div class="section-title" style="margin-bottom:10px">近似値出現確率テーブル</div>
        <div style="display:flex;gap:12px;margin-bottom:12px;align-items:flex-end;flex-wrap:wrap">
          <div>
            <label>基準価格 (自動=現在値)</label>
            <input type="number" id="refPrice" placeholder="自動" style="width:140px">
          </div>
          <div>
            <label>許容範囲 ±%</label>
            <input type="number" id="tolerance" value="2" min="0.1" max="20" step="0.1" style="width:100px">
          </div>
          <button class="btn btn-secondary btn-sm" onclick="buildProbTable()">計算</button>
        </div>
        <div id="prob-table-area"></div>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">確率密度推定 (KDE)</div>
        <canvas id="chart-kde"></canvas>
      </div>
    </div>
  </div>
</div>

<script>
// ─── State ────────────────────────────────────────────────
let rawData = [];       // {date, open, high, low, close, volume}
let filteredData = [];
let charts = {};

// ─── Utils ────────────────────────────────────────────────
function log(msg, type='info') {
  const el = document.getElementById('log');
  const d = document.createElement('div');
  d.className = type;
  d.textContent = `> ${msg}`;
  el.appendChild(d);
  el.scrollTop = el.scrollHeight;
}
function showLoading(v) {
  document.getElementById('loading').classList.toggle('show', v);
}
function ev(e) { e.preventDefault(); document.getElementById('dropZone').classList.add('drag'); }
function fmtN(n, dec=2) { return isNaN(n) ? '─' : n.toFixed(dec); }
function fmtPct(n) { return isNaN(n) ? '─' : (n >= 0 ? '+' : '') + n.toFixed(2) + '%'; }
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
function std(arr) { const m=avg(arr); return Math.sqrt(arr.reduce((a,b)=>a+(b-m)**2,0)/arr.length); }

function ma(arr, n) {
  return arr.map((_,i) => i < n-1 ? null : avg(arr.slice(i-n+1, i+1)));
}

function destroyChart(id) {
  if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}

// ─── API Fetch (multi-proxy fallback + Stooq CSV) ─────────
async function fetchFromAPI() {
  const sym = document.getElementById('symbolInput').value.trim().toUpperCase();
  const period = document.getElementById('periodSelect').value;
  const interval = document.getElementById('intervalSelect').value;
  if (!sym) { log('シンボルを入力してください', 'err'); return; }
  showLoading(true);
  log(`取得中: ${sym} ...`, 'info');

  // ── 手法1: Stooq CSV (日本株・米株広くカバー、CORS制限なし) ──
  const stooqSym = toStooqSymbol(sym);
  try {
    const data = await fetchStooq(stooqSym, period);
    if (data && data.length > 5) {
      rawData = data;
      log(`Stooq取得完了: ${rawData.length}件`, 'ok');
      document.getElementById('stat-symbol').textContent = `${sym} (${rawData.length}件)`;
      initRange(); applyRange();
      showLoading(false);
      return;
    }
  } catch(e) { log(`Stooq失敗: ${e.message}`, 'info'); }

  // ── 手法2: Yahoo Finance v8 (複数プロキシ順次試行) ──
  const yahooUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?range=${period}&interval=${interval}&includePrePost=false`;
  const proxies = [
    u => `https://corsproxy.io/?${encodeURIComponent(u)}`,
    u => `https://proxy.cors.sh/${u}`,
    u => `https://thingproxy.freeboard.io/fetch/${u}`,
    u => `https://cors-anywhere.herokuapp.com/${u}`,
  ];

  for (const makeProxy of proxies) {
    try {
      const proxyUrl = makeProxy(yahooUrl);
      log(`プロキシ試行中...`, 'info');
      const res = await fetchWithTimeout(proxyUrl, 6000);
      if (!res.ok) continue;
      const text = await res.text();
      const data = JSON.parse(text);
      const result = data.chart?.result?.[0];
      if (!result) continue;
      const ts = result.timestamp;
      const q = result.indicators.quote[0];
      rawData = ts.map((t,i) => ({
        date: new Date(t*1000).toISOString().split('T')[0],
        open: q.open[i], high: q.high[i], low: q.low[i],
        close: q.close[i], volume: q.volume[i]
      })).filter(d => d.close != null);
      log(`Yahoo Finance取得完了: ${rawData.length}件`, 'ok');
      document.getElementById('stat-symbol').textContent = `${sym} (${rawData.length}件)`;
      initRange(); applyRange();
      showLoading(false);
      return;
    } catch(e) { /* next proxy */ }
  }

  log('全プロキシ失敗。CSVをご利用ください', 'err');
  showLoadingError();
  showLoading(false);
}

function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url, { mode: 'cors' }),
    new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))
  ]);
}

// Stooq シンボル変換 (7203.T7203.jp, AAPL → aapl.us, BTC-USD → btc.v)
function toStooqSymbol(sym) {
  if (/^[\d]{4}\.T$/.test(sym)) return sym.replace('.T', '.jp').toLowerCase();
  if (/^[\d]{4}$/.test(sym)) return sym + '.jp';
  if (/BTC|ETH|XRP/i.test(sym)) return sym.replace(/-USD$/i, '').toLowerCase() + '.v';
  if (/\^N225/.test(sym)) return '^nkx';
  if (/\^DJI/.test(sym)) return '^dji';
  if (/\^GSPC/.test(sym)) return '^spx';
  return sym.toLowerCase() + '.us';
}

// Stooq CSV 取得
async function fetchStooq(sym, period) {
  const daysMap = { '1mo':30,'3mo':90,'6mo':180,'1y':365,'2y':730,'5y':1825 };
  const days = daysMap[period] || 365;
  const endDate = new Date();
  const startDate = new Date(); startDate.setDate(endDate.getDate() - days);
  const fmt = d => d.toISOString().split('T')[0].replace(/-/g,'');
  const url = `https://stooq.com/q/d/l/?s=${encodeURIComponent(sym)}&d1=${fmt(startDate)}&d2=${fmt(endDate)}&i=d`;
  log(`Stooq: ${url}`, 'info');
  const res = await fetchWithTimeout(url, 8000);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const text = await res.text();
  if (text.includes('No data') || text.length < 50) throw new Error('データなし');
  const lines = text.trim().split('\n');
  const header = lines[0].toLowerCase().split(',');
  const di = header.findIndex(h=>h.includes('date'));
  const oi = header.findIndex(h=>h.includes('open'));
  const hi = header.findIndex(h=>h.includes('high'));
  const li = header.findIndex(h=>h.includes('low'));
  const ci = header.findIndex(h=>h.includes('close'));
  const vi = header.findIndex(h=>h.includes('vol'));
  if (ci < 0) throw new Error('Close列なし');
  return lines.slice(1).map(l => {
    const c = l.split(',');
    return { date: c[di]||'', open: parseFloat(c[oi]), high: parseFloat(c[hi]), low: parseFloat(c[li]), close: parseFloat(c[ci]), volume: vi>=0 ? parseFloat(c[vi]) : 0 };
  }).filter(d => !isNaN(d.close) && d.date);
}

function showLoadingError() {
  document.getElementById('stat-symbol').textContent = '取得失敗 ─ CSVをご利用ください';
  // Show helper in log
  log('─── CSVの用意方法 ───', 'info');
  log('Yahoo Finance → 銘柄ページ → 履歴データ → ダウンロード', 'info');
  log('Stooq.com → 銘柄 → Dataタブ → CSVダウンロード', 'info');
}

// ─── CSV Load ─────────────────────────────────────────────
function dropFile(e) {
  e.preventDefault();
  document.getElementById('dropZone').classList.remove('drag');
  const file = e.dataTransfer.files[0];
  if (file) parseCSVFile(file);
}
function loadCSV(input) {
  if (input.files[0]) parseCSVFile(input.files[0]);
}
function parseCSVFile(file) {
  const reader = new FileReader();
  reader.onload = e => {
    try {
      const lines = e.target.result.trim().split('\n');
      const header = lines[0].split(',').map(h => h.trim().toLowerCase().replace(/[""]/g,''));
      log(`CSV列: ${header.join(', ')}`, 'info');

      const idx = {
        date: header.findIndex(h => /date|time|日付/.test(h)),
        open: header.findIndex(h => /open|始値/.test(h)),
        high: header.findIndex(h => /high|高値/.test(h)),
        low: header.findIndex(h => /low|安値/.test(h)),
        close: header.findIndex(h => /close|終値|adj/.test(h)),
        volume: header.findIndex(h => /vol|出来高/.test(h)),
      };
      if (idx.close < 0) throw new Error('Close列が見つかりません');
      if (idx.date < 0) idx.date = 0;

      rawData = lines.slice(1).map(line => {
        const c = line.split(',').map(v => v.trim().replace(/[""]/g,''));
        return {
          date: c[idx.date],
          open: idx.open >= 0 ? parseFloat(c[idx.open]) : null,
          high: idx.high >= 0 ? parseFloat(c[idx.high]) : null,
          low: idx.low >= 0 ? parseFloat(c[idx.low]) : null,
          close: parseFloat(c[idx.close]),
          volume: idx.volume >= 0 ? parseFloat(c[idx.volume]) : 0,
        };
      }).filter(d => !isNaN(d.close));

      rawData.sort((a,b) => a.date.localeCompare(b.date));
      log(`CSVロード: ${rawData.length}件`, 'ok');
      document.getElementById('stat-symbol').textContent = `CSV (${rawData.length}件)`;
      initRange();
      applyRange();
    } catch(e) { log(`CSV解析エラー: ${e.message}`, 'err'); }
  };
  reader.readAsText(file);
}

// ─── Range ────────────────────────────────────────────────
function initRange() {
  if (!rawData.length) return;
  const dates = rawData.map(d => d.date);
  document.getElementById('rangeStart').value = dates[0];
  document.getElementById('rangeEnd').value = dates[dates.length-1];
  document.getElementById('nDays').max = rawData.length;
}
function applyRange() {
  if (!rawData.length) { log('データがありません', 'err'); return; }
  const nDays = parseInt(document.getElementById('nDays').value);
  const start = document.getElementById('rangeStart').value;
  const end = document.getElementById('rangeEnd').value;

  filteredData = rawData.filter(d => (!start || d.date >= start) && (!end || d.date <= end));
  if (filteredData.length > nDays) filteredData = filteredData.slice(-nDays);

  const dates = filteredData.map(d=>d.date);
  document.getElementById('stat-range').textContent = `範囲: ${dates[0]} ─ ${dates[dates.length-1]} (${filteredData.length}日)`;
  log(`範囲適用: ${filteredData.length}件`, 'ok');
  runAnalysis();
}

// ─── Main Analysis ─────────────────────────────────────────
function runAnalysis() {
  if (!filteredData.length) { log('フィルタ後データなし', 'err'); return; }
  const closes = filteredData.map(d => d.close);
  const returns = closes.slice(1).map((c,i) => (c - closes[i]) / closes[i] * 100);

  buildOverview(closes, returns);
  buildPriceChart(closes);
  buildVolatilityTab(closes, returns);
  buildDistribution(returns);
  buildProbTable();
}

// ─── OVERVIEW ─────────────────────────────────────────────
function buildOverview(closes, returns) {
  const first = closes[0], last = closes[closes.length-1];
  const totalRet = (last - first) / first * 100;
  const volAnnual = std(returns) * Math.sqrt(252);
  const maxDD = calcMaxDD(closes);
  const sharpe = (avg(returns) / std(returns)) * Math.sqrt(252);

  const kpiArea = document.getElementById('kpi-area');
  kpiArea.innerHTML = `
    <div class="kpi-card">
      <div class="kpi-label">現在価格</div>
      <div class="kpi-value neutral">${fmtN(last,0)}</div>
      <div class="kpi-sub">始値: ${fmtN(first,0)}</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">期間リターン</div>
      <div class="kpi-value ${totalRet>=0?'up':'down'}">${fmtPct(totalRet)}</div>
      <div class="kpi-sub">日次平均: ${fmtPct(avg(returns))}</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">年率ボラティリティ</div>
      <div class="kpi-value ${volAnnual>30?'down':volAnnual>15?'neutral':'up'}">${fmtN(volAnnual,1)}%</div>
      <div class="kpi-sub">日次σ: ${fmtN(std(returns),2)}%</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">最大ドローダウン</div>
      <div class="kpi-value down">-${fmtN(maxDD,1)}%</div>
      <div class="kpi-sub">&nbsp;</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">Sharpe比 (年率)</div>
      <div class="kpi-value ${sharpe>=1?'up':sharpe>=0?'neutral':'down'}">${fmtN(sharpe,2)}</div>
      <div class="kpi-sub">リスク調整リターン</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">期間データ数</div>
      <div class="kpi-value neutral">${filteredData.length}</div>
      <div class="kpi-sub">${filteredData[0]?.date} ─</div>
    </div>
  `;

  // Trend summary
  const maS = parseInt(document.getElementById('maShort').value)||5;
  const maL = parseInt(document.getElementById('maLong').value)||25;
  const maShortArr = ma(closes, maS);
  const maLongArr = ma(closes, maL);
  const lastS = maShortArr[maShortArr.length-1];
  const lastL = maLongArr[maLongArr.length-1];
  const trendBull = lastS > lastL;
  const posRetPct = returns.filter(r=>r>0).length / returns.length * 100;

  document.getElementById('trend-summary').innerHTML = `
    <div class="section-title" style="margin-bottom:10px">トレンドサマリー</div>
    <div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
      <span class="trend-badge ${trendBull?'trend-bull':'trend-bear'}">${trendBull?'▲ BULLISH':'▼ BEARISH'}</span>
      <span style="color:var(--text2);font-size:12px">MA${maS}(${fmtN(lastS,0)}) vs MA${maL}(${fmtN(lastL,0)})</span>
      <span style="color:var(--text2);font-size:12px">上昇日: ${fmtN(posRetPct,1)}%</span>
      <span class="trend-badge ${volAnnual>30?'trend-bear':volAnnual>15?'trend-neutral':'trend-bull'}">ボラ: ${volAnnual>30?'HIGH':volAnnual>15?'MED':'LOW'}</span>
    </div>
    <div style="margin-top:12px">
      <div class="prob-bar-wrap">
        <div class="prob-bar-label"><span>上昇日比率</span><span>${fmtN(posRetPct,1)}%</span></div>
        <div class="prob-bar-bg"><div class="prob-bar-fill" style="width:${posRetPct}%;background:var(--up)"></div></div>
      </div>
      <div class="prob-bar-wrap">
        <div class="prob-bar-label"><span>ボラティリティ水準</span><span>${fmtN(Math.min(volAnnual,80)/80*100,0)}% of scale</span></div>
        <div class="prob-bar-bg"><div class="prob-bar-fill" style="width:${Math.min(volAnnual,80)/80*100}%;background:${volAnnual>30?'var(--down)':volAnnual>15?'var(--warn)':'var(--up)'}"></div></div>
      </div>
    </div>
  `;

  // Overview price chart
  destroyChart('overview-price');
  const ctx1 = document.getElementById('chart-overview-price').getContext('2d');
  const labels = filteredData.map(d=>d.date);
  charts['overview-price'] = new Chart(ctx1, {
    type: 'line',
    data: { labels, datasets: [{ label: '終値', data: closes, borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, fill: { target: 'origin', above: 'rgba(0,229,255,0.05)' } }] },
    options: chartOpts('価格', true)
  });

  // Return histogram
  destroyChart('overview-dist');
  const bins = buildBins(returns, 0.5);
  const ctx2 = document.getElementById('chart-overview-dist').getContext('2d');
  charts['overview-dist'] = new Chart(ctx2, {
    type: 'bar',
    data: { labels: bins.labels, datasets: [{ label: '頻度', data: bins.counts, backgroundColor: bins.labels.map(l => parseFloat(l) >= 0 ? 'rgba(0,230,118,0.6)' : 'rgba(255,23,68,0.6)'), borderWidth: 0 }] },
    options: chartOpts('日次リターン%')
  });

  document.getElementById('dot-vol').className = `dot ${volAnnual>30?'err':volAnnual>15?'warn':'ok'}`;
  document.getElementById('stat-vol').textContent = `ボラ: ${fmtN(volAnnual,1)}% (年率)`;
}

// ─── PRICE CHART ───────────────────────────────────────────
function buildPriceChart(closes) {
  const maS = parseInt(document.getElementById('maShort').value)||5;
  const maL = parseInt(document.getElementById('maLong').value)||25;
  const labels = filteredData.map(d=>d.date);
  const volumes = filteredData.map(d=>d.volume||0);

  destroyChart('price');
  const ctx = document.getElementById('chart-price').getContext('2d');
  charts['price'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [
        { label: '終値', data: closes, borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, order: 1 },
        { label: `MA${maS}`, data: ma(closes, maS), borderColor: '#ffea00', borderWidth: 1, pointRadius: 0, borderDash: [3,3], order: 2 },
        { label: `MA${maL}`, data: ma(closes, maL), borderColor: '#ff6b35', borderWidth: 1, pointRadius: 0, borderDash: [6,3], order: 3 },
      ]
    },
    options: chartOpts('価格')
  });

  destroyChart('volume');
  const ctx2 = document.getElementById('chart-volume').getContext('2d');
  charts['volume'] = new Chart(ctx2, {
    type: 'bar',
    data: { labels, datasets: [{ label: '出来高', data: volumes, backgroundColor: 'rgba(0,229,255,0.3)', borderWidth: 0 }] },
    options: chartOpts('出来高')
  });
}

// ─── VOLATILITY ────────────────────────────────────────────
function buildVolatilityTab(closes, returns) {
  const volW = parseInt(document.getElementById('volWindow').value)||20;
  const labels = filteredData.map(d=>d.date).slice(volW);

  const rollingVol = [];
  for (let i = volW; i <= returns.length; i++) {
    rollingVol.push(std(returns.slice(i-volW, i)) * Math.sqrt(252));
  }

  destroyChart('vol');
  const ctx = document.getElementById('chart-vol').getContext('2d');
  const avgVol = avg(rollingVol);
  charts['vol'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [
        { label: `ローリングVol(${volW}日)`, data: rollingVol, borderColor: '#ff6b35', borderWidth: 1.5, pointRadius: 0, fill: { target: 'origin', above: 'rgba(255,107,53,0.08)' } },
        { label: '平均', data: new Array(labels.length).fill(avgVol), borderColor: '#ffea00', borderWidth: 1, borderDash: [4,4], pointRadius: 0 },
      ]
    },
    options: chartOpts('ボラ(年率%)')
  });

  // Classification
  const highThresh = avgVol * 1.5;
  const lowThresh = avgVol * 0.6;
  const highVol = rollingVol.filter(v => v > highThresh);
  const lowVol = rollingVol.filter(v => v < lowThresh);
  const medVol = rollingVol.filter(v => v >= lowThresh && v <= highThresh);

  document.getElementById('vol-classification').innerHTML = `
    <div class="vol-indicator high"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--down)">高ボラ期</div><div style="font-size:10px;color:var(--text2)">>${fmtN(highThresh,1)}% 年率</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--down)">${highVol.length}日 (${fmtN(highVol.length/rollingVol.length*100,0)}%)</div></div>
    <div class="vol-indicator med"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--warn)">中ボラ期</div><div style="font-size:10px;color:var(--text2)">${fmtN(lowThresh,1)}─${fmtN(highThresh,1)}%</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--warn)">${medVol.length}日 (${fmtN(medVol.length/rollingVol.length*100,0)}%)</div></div>
    <div class="vol-indicator low"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--up)">低ボラ期</div><div style="font-size:10px;color:var(--text2)"><${fmtN(lowThresh,1)}%</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--up)">${lowVol.length}日 (${fmtN(lowVol.length/rollingVol.length*100,0)}%)</div></div>
    <div style="margin-top:8px;font-size:11px;color:var(--text2)">
      高ボラ期 平均日次リターン: <span style="color:var(--accent)">${fmtPct(getReturnsByVolRegime(returns, rollingVol, volW, 'high', highThresh))}</span><br>
      低ボラ期 平均日次リターン: <span style="color:var(--accent)">${fmtPct(getReturnsByVolRegime(returns, rollingVol, volW, 'low', lowThresh))}</span>
    </div>
  `;

  // Vol vs return bar
  const hR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'high', highThresh);
  const lR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'low', lowThresh);
  const mR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'med', null, lowThresh, highThresh);
  const binsH = buildBins(hR, 0.5);
  const binsL = buildBins(lR, 0.5);
  const allLabels = [...new Set([...binsH.labels, ...binsL.labels])].sort((a,b)=>parseFloat(a)-parseFloat(b));

  destroyChart('vol-return');
  const ctx2 = document.getElementById('chart-vol-return').getContext('2d');
  charts['vol-return'] = new Chart(ctx2, {
    type: 'bar',
    data: {
      labels: allLabels,
      datasets: [
        { label: '高ボラ期', data: allLabels.map(l => (binsH.map[l]||0)), backgroundColor: 'rgba(255,23,68,0.5)', borderWidth: 0 },
        { label: '低ボラ期', data: allLabels.map(l => (binsL.map[l]||0)), backgroundColor: 'rgba(0,230,118,0.5)', borderWidth: 0 },
      ]
    },
    options: { ...chartOpts('頻度'), scales: { x: { stacked: false, ...axisStyle() }, y: { ...axisStyle() } } }
  });
}

function getReturnsByVolRegime(returns, rollingVol, volW, regime, thresh, low, high) {
  const arr = getReturnsArrByVolRegime(returns, rollingVol, volW, regime, thresh, low, high);
  return arr.length ? avg(arr) : NaN;
}
function getReturnsArrByVolRegime(returns, rollingVol, volW, regime, thresh, low, high) {
  const arr = [];
  for (let i = 0; i < rollingVol.length; i++) {
    const v = rollingVol[i];
    const ri = i + volW;
    if (ri >= returns.length) break;
    let match = false;
    if (regime === 'high') match = v > thresh;
    else if (regime === 'low') match = v < thresh;
    else match = v >= low && v <= high;
    if (match) arr.push(returns[ri]);
  }
  return arr;
}

// ─── DISTRIBUTION ──────────────────────────────────────────
function buildDistribution(returns) {
  const binSize = parseFloat(document.getElementById('binSize').value)||1;
  const bins = buildBins(returns, binSize);

  destroyChart('hist');
  const ctx1 = document.getElementById('chart-hist').getContext('2d');
  charts['hist'] = new Chart(ctx1, {
    type: 'bar',
    data: {
      labels: bins.labels,
      datasets: [{
        label: '頻度',
        data: bins.counts,
        backgroundColor: bins.labels.map(l => parseFloat(l) >= 0 ? 'rgba(0,230,118,0.6)' : 'rgba(255,23,68,0.6)'),
        borderWidth: 0
      }]
    },
    options: chartOpts('日次リターン%')
  });

  // ACF
  const lags = 20;
  const acfVals = [];
  const m = avg(returns);
  const denom = returns.reduce((a,r)=>a+(r-m)**2, 0);
  for (let k=0; k<=lags; k++) {
    let num = 0;
    for (let i=k; i<returns.length; i++) num += (returns[i]-m)*(returns[i-k]-m);
    acfVals.push(num/denom);
  }
  destroyChart('acf');
  const ctx2 = document.getElementById('chart-acf').getContext('2d');
  const confInterval = 1.96 / Math.sqrt(returns.length);
  charts['acf'] = new Chart(ctx2, {
    type: 'bar',
    data: {
      labels: acfVals.map((_,i) => `L${i}`),
      datasets: [
        { label: 'ACF', data: acfVals, backgroundColor: acfVals.map((v,i) => i===0?'transparent':Math.abs(v)>confInterval?'rgba(255,234,0,0.7)':'rgba(0,229,255,0.4)'), borderWidth: 0 },
        { label: '+95%CI', data: new Array(lags+1).fill(confInterval), type: 'line', borderColor: 'rgba(255,107,53,0.6)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
        { label: '-95%CI', data: new Array(lags+1).fill(-confInterval), type: 'line', borderColor: 'rgba(255,107,53,0.6)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
      ]
    },
    options: chartOpts('自己相関係数')
  });

  // Monthly returns
  const monthly = {};
  filteredData.forEach((d, i) => {
    if (i === 0) return;
    const m = d.date.slice(0,7);
    if (!monthly[m]) monthly[m] = [];
    const r = (filteredData[i].close - filteredData[i-1].close) / filteredData[i-1].close * 100;
    monthly[m].push(r);
  });
  const mLabels = Object.keys(monthly).sort();
  const mVals = mLabels.map(k => avg(monthly[k]));
  destroyChart('monthly');
  const ctx3 = document.getElementById('chart-monthly').getContext('2d');
  charts['monthly'] = new Chart(ctx3, {
    type: 'bar',
    data: {
      labels: mLabels,
      datasets: [{ label: '月平均リターン%', data: mVals, backgroundColor: mVals.map(v => v>=0?'rgba(0,230,118,0.6)':'rgba(255,23,68,0.6)'), borderWidth: 0 }]
    },
    options: chartOpts('%')
  });
}

// ─── PROBABILITY ───────────────────────────────────────────
function buildProbTable() {
  if (!filteredData.length) return;
  const closes = filteredData.map(d => d.close);
  const last = closes[closes.length - 1];
  const refInput = document.getElementById('refPrice').value;
  const ref = refInput ? parseFloat(refInput) : last;
  const tol = parseFloat(document.getElementById('tolerance').value) || 2;

  const returns = closes.slice(1).map((c,i)=>(c-closes[i])/closes[i]*100);

  const ranges = [-10, -5, -3, -2, -1, -0.5, 0, 0.5, 1, 2, 3, 5, 10];
  const tableRows = [];
  for (let i=0; i<ranges.length-1; i++) {
    const lo = ranges[i], hi = ranges[i+1];
    const cnt = returns.filter(r => r >= lo && r < hi).length;
    const prob = cnt / returns.length * 100;
    tableRows.push({ range: `${fmtPct(lo)} ~ ${fmtPct(hi)}`, cnt, prob });
  }

  // Near-price probability
  const nearCount = closes.filter(c => Math.abs(c - ref) / ref * 100 <= tol).length;
  const nearProb = nearCount / closes.length * 100;

  document.getElementById('prob-table-area').innerHTML = `
    <div style="margin-bottom:12px;padding:10px;background:var(--bg);border-radius:4px;border:1px solid var(--border)">
      <span style="font-size:11px;color:var(--text2)">基準価格 </span><span style="font-family:'Space Mono',monospace;color:var(--accent)">${fmtN(ref,0)}</span>
      <span style="font-size:11px;color:var(--text2);margin-left:16px">±${tol}% 範囲内の出現確率: </span>
      <span style="font-family:'Space Mono',monospace;font-size:16px;color:${nearProb>30?'var(--up)':'var(--accent)'}">${fmtN(nearProb,1)}%</span>
      <span style="font-size:10px;color:var(--text2);margin-left:8px">(${nearCount}/${closes.length}日)</span>
    </div>
    <table class="dist-table">
      <thead><tr><th>翌日リターン帯</th><th>日数</th><th>確率</th><th>バー</th></tr></thead>
      <tbody>
        ${tableRows.map(r => `
          <tr>
            <td>${r.range}</td>
            <td>${r.cnt}</td>
            <td style="color:${r.prob>15?'var(--accent)':'var(--text)'}">${fmtN(r.prob,1)}%</td>
            <td style="width:120px"><div style="height:6px;background:var(--bg);border-radius:2px;overflow:hidden"><div style="width:${Math.min(r.prob*3,100)}%;height:100%;background:var(--accent);border-radius:2px"></div></div></td>
          </tr>
        `).join('')}
      </tbody>
    </table>
  `;

  // KDE
  buildKDE(returns);
}

function buildKDE(returns) {
  const min = Math.min(...returns) - 1;
  const max = Math.max(...returns) + 1;
  const step = 0.2;
  const xVals = [];
  for (let x = min; x <= max; x += step) xVals.push(x);

  const h = 1.06 * std(returns) * Math.pow(returns.length, -0.2);
  const kdeVals = xVals.map(x => {
    return returns.reduce((sum, r) => {
      const u = (x - r) / h;
      return sum + Math.exp(-0.5 * u * u) / (Math.sqrt(2 * Math.PI));
    }, 0) / (returns.length * h);
  });

  // Normal distribution for comparison
  const m = avg(returns), s = std(returns);
  const normalVals = xVals.map(x => Math.exp(-0.5*((x-m)/s)**2) / (s * Math.sqrt(2*Math.PI)));

  destroyChart('kde');
  const ctx = document.getElementById('chart-kde').getContext('2d');
  charts['kde'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels: xVals.map(v => fmtN(v,1)),
      datasets: [
        { label: '実績KDE', data: kdeVals, borderColor: '#00e5ff', borderWidth: 2, pointRadius: 0, fill: { target: 'origin', above: 'rgba(0,229,255,0.08)' } },
        { label: '正規分布', data: normalVals, borderColor: '#ff6b35', borderWidth: 1.5, borderDash: [6,3], pointRadius: 0 },
      ]
    },
    options: chartOpts('確率密度')
  });
}

// ─── Helpers ───────────────────────────────────────────────
function calcMaxDD(closes) {
  let peak = closes[0], maxDD = 0;
  for (const c of closes) {
    if (c > peak) peak = c;
    const dd = (peak - c) / peak * 100;
    if (dd > maxDD) maxDD = dd;
  }
  return maxDD;
}
function buildBins(returns, step) {
  const min = Math.floor(Math.min(...returns)/step)*step;
  const max = Math.ceil(Math.max(...returns)/step)*step;
  const labels = [], counts = [], map = {};
  for (let v = min; v <= max; v += step) {
    const l = fmtN(v,1);
    labels.push(l);
    const cnt = returns.filter(r => r >= v && r < v+step).length;
    counts.push(cnt);
    map[l] = cnt;
  }
  return { labels, counts, map };
}

function axisStyle() {
  return {
    ticks: { color: '#6a8090', font: { family: "'Space Mono'" , size: 9 } },
    grid: { color: 'rgba(36,44,58,0.8)' },
    border: { color: '#242c3a' }
  };
}
function chartOpts(yLabel, tension=false) {
  return {
    responsive: true,
    maintainAspectRatio: true,
    interaction: { mode: 'index', intersect: false },
    plugins: {
      legend: { labels: { color: '#6a8090', font: { family: "'Space Mono'", size: 10 }, boxWidth: 20 } },
      tooltip: {
        backgroundColor: 'rgba(17,20,24,0.95)',
        borderColor: '#242c3a', borderWidth: 1,
        titleColor: '#00e5ff', bodyColor: '#c8d8e8',
        titleFont: { family: "'Space Mono'" }, bodyFont: { family: "'Space Mono'", size: 11 }
      }
    },
    elements: { line: { tension: tension ? 0.2 : 0 } },
    scales: { x: axisStyle(), y: { ...axisStyle(), title: { display: !!yLabel, text: yLabel, color: '#6a8090', font: { size: 10 } } } }
  };
}

// ─── Tab switch ────────────────────────────────────────────
function switchTab(name) {
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
  event.target.classList.add('active');
  document.getElementById('tab-' + name).classList.add('active');
}

// ─── Demo / No data state ──────────────────────────────────
document.querySelectorAll('.tab-content').forEach(tc => {
  if (!tc.querySelector('canvas')) return;
  // leave empty until data loads
});
log('データを取得またはCSVをロードしてください', 'info');
</script>
</body>
</html>

・バージョン1.1のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>株価傾向分析ツール</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #0a0c10;
    --bg2: #111418;
    --bg3: #1a1f28;
    --border: #242c3a;
    --accent: #00e5ff;
    --accent2: #ff6b35;
    --accent3: #7fff6b;
    --text: #c8d8e8;
    --text2: #6a8090;
    --up: #00e676;
    --down: #ff1744;
    --warn: #ffea00;
    --panel: rgba(26,31,40,0.9);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: 'Noto Sans JP', sans-serif;
    font-size: 13px;
    min-height: 100vh;
    overflow-x: hidden;
  }
  body::before {
    content: '';
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    background:
      radial-gradient(ellipse at 20% 20%, rgba(0,229,255,0.04) 0%, transparent 60%),
      radial-gradient(ellipse at 80% 80%, rgba(255,107,53,0.04) 0%, transparent 60%);
    pointer-events: none;
    z-index: 0;
  }
  header {
    padding: 16px 24px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    gap: 16px;
    background: rgba(10,12,16,0.95);
    position: sticky; top: 0; z-index: 100;
    backdrop-filter: blur(10px);
  }
  .logo {
    font-family: 'Space Mono', monospace;
    font-size: 18px;
    color: var(--accent);
    letter-spacing: 2px;
    white-space: nowrap;
  }
  .logo span { color: var(--accent2); }
  .status-bar {
    flex: 1;
    display: flex;
    gap: 20px;
    align-items: center;
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    color: var(--text2);
    overflow: hidden;
  }
  .stat-pill {
    display: flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
  }
  .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
  .dot.warn { background: var(--warn); }
  .dot.ok { background: var(--up); }
  .main-layout {
    display: grid;
    grid-template-columns: 280px 1fr;
    grid-template-rows: auto 1fr;
    gap: 0;
    min-height: calc(100vh - 56px);
    position: relative;
    z-index: 1;
  }
  .sidebar {
    grid-row: 1 / 3;
    border-right: 1px solid var(--border);
    background: var(--bg2);
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 16px;
    overflow-y: auto;
  }
  .content-area {
    display: flex;
    flex-direction: column;
    gap: 0;
    overflow: hidden;
  }
  .section-title {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    letter-spacing: 2px;
    color: var(--text2);
    text-transform: uppercase;
    margin-bottom: 8px;
    padding-bottom: 6px;
    border-bottom: 1px solid var(--border);
  }
  .panel {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 14px;
  }
  label { display: block; color: var(--text2); font-size: 11px; margin-bottom: 4px; }
  input, select {
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 7px 10px;
    border-radius: 4px;
    font-size: 12px;
    font-family: inherit;
    outline: none;
    transition: border-color 0.2s;
  }
  input:focus, select:focus { border-color: var(--accent); }
  input[type="range"] {
    padding: 4px 0;
    border: none;
    background: transparent;
    accent-color: var(--accent);
  }
  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 12px;
    font-family: 'Space Mono', monospace;
    cursor: pointer;
    border: none;
    transition: all 0.2s;
    width: 100%;
  }
  .btn-primary { background: var(--accent); color: #000; font-weight: 700; }
  .btn-primary:hover { background: #33eeff; box-shadow: 0 0 20px rgba(0,229,255,0.3); }
  .btn-secondary { background: transparent; color: var(--accent2); border: 1px solid var(--accent2); }
  .btn-secondary:hover { background: rgba(255,107,53,0.1); }
  .btn-export { background: transparent; color: var(--accent3); border: 1px solid var(--accent3); }
  .btn-export:hover { background: rgba(127,255,107,0.1); }
  .btn-sm { padding: 5px 10px; width: auto; font-size: 11px; }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }
  .file-drop {
    border: 2px dashed var(--border);
    border-radius: 6px;
    padding: 20px;
    text-align: center;
    cursor: pointer;
    transition: all 0.2s;
    color: var(--text2);
    font-size: 12px;
  }
  .file-drop:hover, .file-drop.drag { border-color: var(--accent); color: var(--accent); }
  .file-drop input { display: none; }
  .tab-bar {
    display: flex;
    border-bottom: 1px solid var(--border);
    background: var(--bg2);
    padding: 0 16px;
    overflow-x: auto;
  }
  .tab-bar::-webkit-scrollbar { height: 3px; }
  .tab-bar::-webkit-scrollbar-thumb { background: var(--border); }
  .tab {
    padding: 12px 16px;
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    letter-spacing: 1px;
    cursor: pointer;
    color: var(--text2);
    border-bottom: 2px solid transparent;
    transition: all 0.2s;
    white-space: nowrap;
  }
  .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
  .tab:hover { color: var(--text); }
  .tab-content {
    display: none;
    padding: 16px;
    flex: 1;
    overflow-y: auto;
  }
  .tab-content.active { display: block; }
  .chart-wrap {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 16px;
    margin-bottom: 16px;
    position: relative;
  }
  .chart-title {
    font-family: 'Space Mono', monospace;
    font-size: 11px;
    letter-spacing: 1px;
    color: var(--text2);
    margin-bottom: 12px;
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .chart-wrap canvas { max-height: 260px; }
  .chart-wrap canvas.tall { max-height: 360px; }
  .export-btn {
    background: transparent;
    border: 1px solid var(--border);
    color: var(--text2);
    border-radius: 3px;
    padding: 2px 8px;
    font-size: 10px;
    font-family: 'Space Mono', monospace;
    cursor: pointer;
    transition: all 0.15s;
  }
  .export-btn:hover { border-color: var(--accent3); color: var(--accent3); }
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
  .kpi-card {
    background: var(--bg3);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 14px;
  }
  .kpi-label { font-size: 10px; color: var(--text2); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
  .kpi-value { font-family: 'Space Mono', monospace; font-size: 22px; font-weight: 700; }
  .kpi-value.up { color: var(--up); }
  .kpi-value.down { color: var(--down); }
  .kpi-value.neutral { color: var(--accent); }
  .kpi-sub { font-size: 10px; color: var(--text2); margin-top: 4px; }
  .trend-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 3px 10px;
    border-radius: 12px;
    font-size: 11px;
    font-family: 'Space Mono', monospace;
    font-weight: 700;
  }
  .trend-bull { background: rgba(0,230,118,0.15); color: var(--up); border: 1px solid var(--up); }
  .trend-bear { background: rgba(255,23,68,0.15); color: var(--down); border: 1px solid var(--down); }
  .trend-neutral { background: rgba(255,234,0,0.1); color: var(--warn); border: 1px solid var(--warn); }
  .prob-bar-wrap { margin-bottom: 8px; }
  .prob-bar-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--text2); margin-bottom: 3px; }
  .prob-bar-bg { background: var(--bg); border-radius: 2px; height: 8px; overflow: hidden; }
  .prob-bar-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
  .vol-indicator {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    background: var(--bg);
    border-radius: 4px;
    margin-bottom: 8px;
    border-left: 3px solid var(--accent);
  }
  .vol-indicator.high { border-left-color: var(--down); }
  .vol-indicator.low { border-left-color: var(--up); }
  .vol-indicator.med { border-left-color: var(--warn); }
  .dist-table { width: 100%; border-collapse: collapse; font-size: 12px; }
  .dist-table th {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    letter-spacing: 1px;
    color: var(--text2);
    text-align: left;
    padding: 8px;
    border-bottom: 1px solid var(--border);
    white-space: nowrap;
  }
  .dist-table td {
    padding: 7px 8px;
    border-bottom: 1px solid rgba(36,44,58,0.5);
    font-family: 'Space Mono', monospace;
    font-size: 11px;
  }
  .dist-table tr:hover td { background: rgba(0,229,255,0.04); }
  #loading {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(10,12,16,0.8);
    z-index: 999;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 16px;
    backdrop-filter: blur(4px);
  }
  #loading.show { display: flex; }
  .spinner {
    width: 40px; height: 40px;
    border: 3px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  #log {
    font-family: 'Space Mono', monospace;
    font-size: 10px;
    color: var(--text2);
    max-height: 80px;
    overflow-y: auto;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 8px;
  }
  #log div { margin-bottom: 2px; }
  #log .ok { color: var(--up); }
  #log .err { color: var(--down); }
  #log .info { color: var(--accent); }
  .no-data {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 200px;
    color: var(--text2);
    font-size: 12px;
    gap: 8px;
  }
  .no-data-icon { font-size: 36px; opacity: 0.3; }
  .range-row { display: flex; gap: 8px; align-items: flex-end; }
  .range-row > * { flex: 1; }
  /* テクニカル指標 シグナルバッジ */
  .sig-buy  { display:inline-block; background:rgba(0,230,118,0.18); color:var(--up);  border:1px solid var(--up);  border-radius:10px; padding:2px 10px; font-size:11px; font-family:'Space Mono',monospace; }
  .sig-sell { display:inline-block; background:rgba(255,23,68,0.15);  color:var(--down);border:1px solid var(--down);border-radius:10px; padding:2px 10px; font-size:11px; font-family:'Space Mono',monospace; }
  .sig-neut { display:inline-block; background:rgba(255,234,0,0.10);  color:var(--warn);border:1px solid var(--warn);border-radius:10px; padding:2px 10px; font-size:11px; font-family:'Space Mono',monospace; }
  .tech-row { display:flex; align-items:center; gap:12px; padding:8px 12px; background:var(--bg); border-radius:4px; margin-bottom:6px; border-left:3px solid var(--border); }
  .tech-row.buy  { border-left-color: var(--up); }
  .tech-row.sell { border-left-color: var(--down); }
  .tech-row.neut { border-left-color: var(--warn); }
  @media (max-width: 768px) {
    .main-layout { grid-template-columns: 1fr; }
    .sidebar { grid-row: auto; border-right: none; border-bottom: 1px solid var(--border); }
    .grid-2, .grid-3 { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>

<div id="loading"><div class="spinner"></div><div style="font-family:'Space Mono',monospace;font-size:12px;color:var(--accent)">データ取得中...</div></div>

<header>
  <div class="logo">QUANT<span>VIEW</span></div>
  <div class="status-bar">
    <div class="stat-pill"><div class="dot" id="dot-data"></div><span id="stat-symbol">─ データ未読み込み</span></div>
    <div class="stat-pill"><div class="dot ok"></div><span id="stat-range">範囲: ─</span></div>
    <div class="stat-pill"><div class="dot warn" id="dot-vol"></div><span id="stat-vol">ボラ: ─</span></div>
  </div>
</header>

<div class="main-layout">
  <!-- SIDEBAR -->
  <div class="sidebar">
    <div>
      <div class="section-title">データソース</div>
      <div class="panel" style="margin-bottom:10px">
        <label>銘柄シンボル (Stooq / Yahoo Finance)</label>
        <input type="text" id="symbolInput" placeholder="例: 7203.T, AAPL, BTC-USD" value="7203.T">
        <div style="font-size:10px;color:var(--text2);margin-top:3px;line-height:1.7">
          日本株: <span style="color:var(--accent)">7203.T</span> &nbsp;
          米株: <span style="color:var(--accent)">AAPL</span> &nbsp;
          BTC: <span style="color:var(--accent)">BTC-USD</span><br>
          日経225: <span style="color:var(--accent)">^N225</span> &nbsp;
          S&amp;P500: <span style="color:var(--accent)">^GSPC</span>
        </div>
        <div style="margin-top:6px; display:flex; gap:6px">
          <select id="periodSelect" style="flex:1">
            <option value="1mo">1ヶ月</option>
            <option value="3mo">3ヶ月</option>
            <option value="6mo">6ヶ月</option>
            <option value="1y" selected>1年</option>
            <option value="2y">2年</option>
            <option value="5y">5年</option>
          </select>
          <select id="intervalSelect" style="flex:1">
            <option value="1d" selected>日足</option>
            <option value="1wk">週足</option>
            <option value="1mo">月足</option>
          </select>
        </div>
        <div style="display:flex;gap:6px;margin-top:8px">
          <button class="btn btn-primary" style="flex:2" onclick="fetchFromAPI()">▶ WEB取得</button>
          <button class="btn btn-secondary" style="flex:1;font-size:11px" onclick="loadDemoData(document.getElementById('symbolInput').value||'DEMO')">📊 デモ</button>
        </div>
      </div>

      <div class="file-drop panel" id="dropZone" onclick="document.getElementById('csvFile').click()" ondragover="ev(event)" ondrop="dropFile(event)">
        <input type="file" id="csvFile" accept=".csv" onchange="loadCSV(this)">
        <div style="font-size:20px;margin-bottom:6px">📂</div>
        <div>CSVファイルをドロップ</div>
        <div style="font-size:10px;margin-top:4px;color:var(--text2)">または クリックして選択</div>
        <div style="font-size:10px;margin-top:6px;color:var(--text2);line-height:1.6">
          CSV入手先:<br>
          ・<a href="https://stooq.com" target="_blank" style="color:var(--accent)">stooq.com</a> (日本株/米株)<br>
          ・Yahoo Finance → 履歴データ
        </div>
      </div>
    </div>

    <div>
      <div class="section-title">集計範囲</div>
      <div class="panel">
        <div class="range-row" style="margin-bottom:8px">
          <div><label>開始日</label><input type="date" id="rangeStart"></div>
          <div><label>終了日</label><input type="date" id="rangeEnd"></div>
        </div>
        <label>最新 N日間のみ</label>
        <div style="display:flex;gap:8px;align-items:center">
          <input type="range" id="nDays" min="10" max="500" value="252" oninput="document.getElementById('nDaysVal').textContent=this.value">
          <span id="nDaysVal" style="font-family:'Space Mono',monospace;font-size:12px;color:var(--accent);min-width:36px">252</span>
        </div>
        <button class="btn btn-secondary" style="margin-top:8px" onclick="applyRange()">範囲を適用</button>
      </div>
    </div>

    <div>
      <div class="section-title">分析設定</div>
      <div class="panel">
        <label>移動平均 (短期)</label>
        <input type="number" id="maShort" value="5" min="2" max="50">
        <label style="margin-top:8px">移動平均 (長期)</label>
        <input type="number" id="maLong" value="25" min="5" max="200">
        <label style="margin-top:8px">ボラティリティ窓 (日)</label>
        <input type="number" id="volWindow" value="20" min="5" max="60">
        <label style="margin-top:8px">価格ビン幅 (%)</label>
        <input type="number" id="binSize" value="1" min="0.1" max="5" step="0.1">
        <label style="margin-top:8px">RSI 期間</label>
        <input type="number" id="rsiPeriod" value="14" min="2" max="50">
        <label style="margin-top:8px">BB 期間 / σ倍率</label>
        <div style="display:flex;gap:6px">
          <input type="number" id="bbPeriod" value="20" min="5" max="100" style="flex:1">
          <input type="number" id="bbMult" value="2" min="0.5" max="4" step="0.1" style="flex:1">
        </div>
        <button class="btn btn-primary" style="margin-top:10px" onclick="runAnalysis()">▶ 分析実行</button>
      </div>
    </div>

    <div>
      <div class="section-title">エクスポート</div>
      <div class="panel" style="display:flex;flex-direction:column;gap:6px">
        <button class="btn btn-export btn-sm" onclick="exportCurrentChartPNG()" style="width:100%">📷 現在のグラフをPNG保存</button>
        <button class="btn btn-export btn-sm" onclick="exportAllPDF()" style="width:100%">📄 全グラフをPDF保存</button>
      </div>
    </div>

    <div>
      <div class="section-title">ログ</div>
      <div id="log"></div>
    </div>
  </div>

  <!-- CONTENT -->
  <div class="content-area">
    <div class="tab-bar" id="main-tab-bar">
      <div class="tab active" onclick="switchTab('overview',this)">概要</div>
      <div class="tab" onclick="switchTab('price',this)">価格チャート</div>
      <div class="tab" onclick="switchTab('technical',this)">テクニカル</div>
      <div class="tab" onclick="switchTab('volatility',this)">ボラティリティ</div>
      <div class="tab" onclick="switchTab('distribution',this)">分布分析</div>
      <div class="tab" onclick="switchTab('probability',this)">確率マトリクス</div>
    </div>

    <!-- OVERVIEW -->
    <div class="tab-content active" id="tab-overview">
      <div id="kpi-area" class="grid-3" style="margin-bottom:16px"></div>
      <div class="grid-2">
        <div class="chart-wrap">
          <div class="chart-title">価格推移 (概要) <button class="export-btn" onclick="exportChartPNG('chart-overview-price','overview-price')">PNG</button></div>
          <canvas id="chart-overview-price"></canvas>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">日次リターン分布 <button class="export-btn" onclick="exportChartPNG('chart-overview-dist','return-dist')">PNG</button></div>
          <canvas id="chart-overview-dist"></canvas>
        </div>
      </div>
      <div id="trend-summary" class="panel" style="margin-top:0"></div>
    </div>

    <!-- PRICE CHART (ローソク足 + MA + 出来高) -->
    <div class="tab-content" id="tab-price">
      <div class="chart-wrap" style="margin-bottom:8px">
        <div class="chart-title">
          <span id="price-chart-label">ローソク足チャート + 移動平均</span>
          <div style="display:flex;gap:8px;align-items:center">
            <select id="chartTypeSelect" style="width:auto;padding:3px 8px;font-size:11px" onchange="rebuildPriceChart()">
              <option value="candlestick">ローソク足</option>
              <option value="line">ライン</option>
            </select>
            <button class="export-btn" onclick="exportChartPNG('chart-price','price-chart')">PNG</button>
          </div>
        </div>
        <canvas id="chart-price" class="tall"></canvas>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">出来高 <button class="export-btn" onclick="exportChartPNG('chart-volume','volume')">PNG</button></div>
        <canvas id="chart-volume"></canvas>
      </div>
    </div>

    <!-- TECHNICAL INDICATORS -->
    <div class="tab-content" id="tab-technical">
      <div id="tech-signal-area" style="margin-bottom:16px"></div>
      <div class="chart-wrap" style="margin-bottom:16px">
        <div class="chart-title">価格 + ボリンジャーバンド <button class="export-btn" onclick="exportChartPNG('chart-bb','bb-chart')">PNG</button></div>
        <canvas id="chart-bb" class="tall"></canvas>
      </div>
      <div class="grid-2">
        <div class="chart-wrap">
          <div class="chart-title">RSI (<span id="rsi-period-label">14</span>) <button class="export-btn" onclick="exportChartPNG('chart-rsi','rsi')">PNG</button></div>
          <canvas id="chart-rsi"></canvas>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">MACD (12,26,9) <button class="export-btn" onclick="exportChartPNG('chart-macd','macd')">PNG</button></div>
          <canvas id="chart-macd"></canvas>
        </div>
      </div>
    </div>

    <!-- VOLATILITY -->
    <div class="tab-content" id="tab-volatility">
      <div class="chart-wrap" style="margin-bottom:16px">
        <div class="chart-title">ローリングボラティリティ (年率換算 %) <button class="export-btn" onclick="exportChartPNG('chart-vol','volatility')">PNG</button></div>
        <canvas id="chart-vol"></canvas>
      </div>
      <div class="grid-2">
        <div>
          <div class="section-title" style="margin-bottom:10px">ボラティリティ分類</div>
          <div id="vol-classification"></div>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">ボラ水準別 翌日リターン分布 <button class="export-btn" onclick="exportChartPNG('chart-vol-return','vol-return')">PNG</button></div>
          <canvas id="chart-vol-return"></canvas>
        </div>
      </div>
    </div>

    <!-- DISTRIBUTION -->
    <div class="tab-content" id="tab-distribution">
      <div class="grid-2" style="margin-bottom:16px">
        <div class="chart-wrap">
          <div class="chart-title">価格変化率ヒストグラム (日次%) <button class="export-btn" onclick="exportChartPNG('chart-hist','histogram')">PNG</button></div>
          <canvas id="chart-hist"></canvas>
        </div>
        <div class="chart-wrap">
          <div class="chart-title">自己相関 (ACF) <button class="export-btn" onclick="exportChartPNG('chart-acf','acf')">PNG</button></div>
          <canvas id="chart-acf"></canvas>
        </div>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">時間的分布 (月別 平均リターン) <button class="export-btn" onclick="exportChartPNG('chart-monthly','monthly-return')">PNG</button></div>
        <canvas id="chart-monthly"></canvas>
      </div>
    </div>

    <!-- PROBABILITY -->
    <div class="tab-content" id="tab-probability">
      <div class="panel" style="margin-bottom:16px">
        <div class="section-title" style="margin-bottom:10px">近似値出現確率テーブル</div>
        <div style="display:flex;gap:12px;margin-bottom:12px;align-items:flex-end;flex-wrap:wrap">
          <div>
            <label>基準価格 (自動=現在値)</label>
            <input type="number" id="refPrice" placeholder="自動" style="width:140px">
          </div>
          <div>
            <label>許容範囲 ±%</label>
            <input type="number" id="tolerance" value="2" min="0.1" max="20" step="0.1" style="width:100px">
          </div>
          <button class="btn btn-secondary btn-sm" onclick="buildProbTable()">計算</button>
        </div>
        <div id="prob-table-area"></div>
      </div>
      <div class="chart-wrap">
        <div class="chart-title">確率密度推定 (KDE) <button class="export-btn" onclick="exportChartPNG('chart-kde','kde')">PNG</button></div>
        <canvas id="chart-kde"></canvas>
      </div>
    </div>
  </div>
</div>

<script>
// ─── State ────────────────────────────────────────────────
let rawData = [];
let filteredData = [];
let charts = {};
let currentTab = 'overview';

// ─── LocalStorage 設定永続化 ──────────────────────────────
const LS_KEY = 'quantview_settings';
const SETTINGS_IDS = ['maShort','maLong','volWindow','binSize','rsiPeriod','bbPeriod','bbMult',
                      'symbolInput','periodSelect','intervalSelect','nDays','chartTypeSelect'];

function saveSettings() {
  const s = {};
  SETTINGS_IDS.forEach(id => {
    const el = document.getElementById(id);
    if (el) s[id] = el.value;
  });
  try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(e) {}
}

function loadSettings() {
  try {
    const raw = localStorage.getItem(LS_KEY);
    if (!raw) return;
    const s = JSON.parse(raw);
    SETTINGS_IDS.forEach(id => {
      const el = document.getElementById(id);
      if (el && s[id] !== undefined) {
        el.value = s[id];
        // nDaysVal 表示も更新
        if (id === 'nDays') {
          const v = document.getElementById('nDaysVal');
          if (v) v.textContent = s[id];
        }
      }
    });
    log('前回の設定を復元しました', 'ok');
  } catch(e) {}
}

// 設定変更時に自動保存(全設定入力にイベントを後付け)
function bindSettingsAutoSave() {
  SETTINGS_IDS.forEach(id => {
    const el = document.getElementById(id);
    if (el) el.addEventListener('change', saveSettings);
  });
  // rangeのoninputはHTMLに直接書いてあるが追加でも問題なし
  const nDaysEl = document.getElementById('nDays');
  if (nDaysEl) nDaysEl.addEventListener('input', saveSettings);
}

// ─── Utils ────────────────────────────────────────────────
function log(msg, type='info') {
  const el = document.getElementById('log');
  const d = document.createElement('div');
  d.className = type;
  d.textContent = `> ${msg}`;
  el.appendChild(d);
  el.scrollTop = el.scrollHeight;
}
function showLoading(v) {
  document.getElementById('loading').classList.toggle('show', v);
}
function ev(e) { e.preventDefault(); document.getElementById('dropZone').classList.add('drag'); }
function fmtN(n, dec=2) { return isNaN(n) ? '─' : n.toFixed(dec); }
function fmtPct(n) { return isNaN(n) ? '─' : (n >= 0 ? '+' : '') + n.toFixed(2) + '%'; }
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
function std(arr) { const m=avg(arr); return Math.sqrt(arr.reduce((a,b)=>a+(b-m)**2,0)/arr.length); }

function ma(arr, n) {
  return arr.map((_,i) => i < n-1 ? null : avg(arr.slice(i-n+1, i+1)));
}

function destroyChart(id) {
  if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}

// ─── Web取得: Python起動スクリプト案内 + サンプルデータ ─────
async function fetchFromAPI() {
  const sym = document.getElementById('symbolInput').value.trim().toUpperCase();
  const period = document.getElementById('periodSelect').value;
  if (!sym) { log('シンボルを入力してください', 'err'); return; }

  const isServer = location.protocol === 'http:' || location.protocol === 'https:';
  if (!isServer) {
    showCorsModal(sym, period);
    return;
  }

  showLoading(true);
  log(`サーバー経由取得中: ${sym}`, 'info');
  try {
    const res = await fetch(`/proxy?sym=${encodeURIComponent(sym)}&period=${period}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    if (!data.length) throw new Error('データなし');
    rawData = data;
    log(`取得完了: ${rawData.length}件`, 'ok');
    document.getElementById('stat-symbol').textContent = `${sym} (${rawData.length}件)`;
    saveSettings();
    initRange(); applyRange();
  } catch(e) {
    log(`エラー: ${e.message}`, 'err');
  } finally { showLoading(false); }
}

// デモ用ランダムウォークデータ生成
function loadDemoData(sym) {
  const daysMap = {
    '7203.T':'トヨタ', 'AAPL':'Apple', 'BTC-USD':'Bitcoin',
    '^N225':'日経225', '^GSPC':'S&P500'
  };
  const name = daysMap[sym] || sym;
  const n = 500;
  const prices = [1000];
  const mu = 0.0003, sigma = 0.015;
  for (let i = 1; i < n; i++) {
    const r = mu + sigma * randn();
    prices.push(prices[i-1] * Math.exp(r));
  }
  const start = new Date('2023-01-05');
  rawData = prices.map((p, i) => {
    const d = new Date(start); d.setDate(start.getDate() + i);
    const spread = p * 0.01;
    const open  = p * (1 + (Math.random()-0.5)*0.005);
    const high  = Math.max(p, open) + Math.random() * spread;
    const low   = Math.min(p, open) - Math.random() * spread;
    return {
      date: d.toISOString().split('T')[0],
      open, high, low, close: p,
      volume: Math.floor(1e6 * (0.5 + Math.random()))
    };
  });
  log(`デモデータ生成: ${name} ${rawData.length}件 (架空データ)`, 'ok');
  document.getElementById('stat-symbol').textContent = `[DEMO] ${name} (${rawData.length}件)`;
  initRange(); applyRange();
}
function randn() {
  let u=0,v=0;
  while(u===0) u=Math.random();
  while(v===0) v=Math.random();
  return Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v);
}

// CORSモーダル表示
function showCorsModal(sym, period) {
  document.getElementById('cors-modal').style.display = 'flex';
  document.getElementById('cors-sym').textContent = sym;
  document.getElementById('cors-period').textContent = period;
}
function closeCorsModal() {
  document.getElementById('cors-modal').style.display = 'none';
}
function loadDemoFromModal() {
  const sym = document.getElementById('symbolInput').value.trim().toUpperCase();
  closeCorsModal();
  loadDemoData(sym);
}

// ─── CSV Load ─────────────────────────────────────────────
function dropFile(e) {
  e.preventDefault();
  document.getElementById('dropZone').classList.remove('drag');
  const file = e.dataTransfer.files[0];
  if (file) parseCSVFile(file);
}
function loadCSV(input) {
  if (input.files[0]) parseCSVFile(input.files[0]);
}
function parseCSVFile(file) {
  const reader = new FileReader();
  reader.onload = e => {
    try {
      const lines = e.target.result.trim().split('\n');
      const header = lines[0].split(',').map(h => h.trim().toLowerCase().replace(/[""]/g,''));
      log(`CSV列: ${header.join(', ')}`, 'info');

      const idx = {
        date: header.findIndex(h => /date|time|日付/.test(h)),
        open: header.findIndex(h => /open|始値/.test(h)),
        high: header.findIndex(h => /high|高値/.test(h)),
        low: header.findIndex(h => /low|安値/.test(h)),
        close: header.findIndex(h => /close|終値|adj/.test(h)),
        volume: header.findIndex(h => /vol|出来高/.test(h)),
      };
      if (idx.close < 0) throw new Error('Close列が見つかりません');
      if (idx.date < 0) idx.date = 0;

      rawData = lines.slice(1).map(line => {
        const c = line.split(',').map(v => v.trim().replace(/[""]/g,''));
        return {
          date: c[idx.date],
          open: idx.open >= 0 ? parseFloat(c[idx.open]) : null,
          high: idx.high >= 0 ? parseFloat(c[idx.high]) : null,
          low: idx.low >= 0 ? parseFloat(c[idx.low]) : null,
          close: parseFloat(c[idx.close]),
          volume: idx.volume >= 0 ? parseFloat(c[idx.volume]) : 0,
        };
      }).filter(d => !isNaN(d.close));

      rawData.sort((a,b) => a.date.localeCompare(b.date));
      log(`CSVロード: ${rawData.length}件`, 'ok');
      document.getElementById('stat-symbol').textContent = `CSV (${rawData.length}件)`;
      initRange();
      applyRange();
    } catch(e) { log(`CSV解析エラー: ${e.message}`, 'err'); }
  };
  reader.readAsText(file);
}

// ─── Range ────────────────────────────────────────────────
function initRange() {
  if (!rawData.length) return;
  const dates = rawData.map(d => d.date);
  document.getElementById('rangeStart').value = dates[0];
  document.getElementById('rangeEnd').value = dates[dates.length-1];
  document.getElementById('nDays').max = rawData.length;
}
function applyRange() {
  if (!rawData.length) { log('データがありません', 'err'); return; }
  const nDays = parseInt(document.getElementById('nDays').value);
  const start = document.getElementById('rangeStart').value;
  const end = document.getElementById('rangeEnd').value;

  filteredData = rawData.filter(d => (!start || d.date >= start) && (!end || d.date <= end));
  if (filteredData.length > nDays) filteredData = filteredData.slice(-nDays);

  const dates = filteredData.map(d=>d.date);
  document.getElementById('stat-range').textContent = `範囲: ${dates[0]} ─ ${dates[dates.length-1]} (${filteredData.length}日)`;
  log(`範囲適用: ${filteredData.length}件`, 'ok');
  saveSettings();
  runAnalysis();
}

// ─── Main Analysis ─────────────────────────────────────────
function runAnalysis() {
  if (!filteredData.length) { log('フィルタ後データなし', 'err'); return; }
  const closes = filteredData.map(d => d.close);
  const returns = closes.slice(1).map((c,i) => (c - closes[i]) / closes[i] * 100);

  buildOverview(closes, returns);
  buildPriceChart(closes);
  buildTechnicalTab(closes);
  buildVolatilityTab(closes, returns);
  buildDistribution(returns);
  buildProbTable();
}

// ─── OVERVIEW ─────────────────────────────────────────────
function buildOverview(closes, returns) {
  const first = closes[0], last = closes[closes.length-1];
  const totalRet = (last - first) / first * 100;
  const volAnnual = std(returns) * Math.sqrt(252);
  const maxDD = calcMaxDD(closes);
  const sharpe = (avg(returns) / std(returns)) * Math.sqrt(252);

  const kpiArea = document.getElementById('kpi-area');
  kpiArea.innerHTML = `
    <div class="kpi-card">
      <div class="kpi-label">現在価格</div>
      <div class="kpi-value neutral">${fmtN(last,0)}</div>
      <div class="kpi-sub">始値: ${fmtN(first,0)}</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">期間リターン</div>
      <div class="kpi-value ${totalRet>=0?'up':'down'}">${fmtPct(totalRet)}</div>
      <div class="kpi-sub">日次平均: ${fmtPct(avg(returns))}</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">年率ボラティリティ</div>
      <div class="kpi-value ${volAnnual>30?'down':volAnnual>15?'neutral':'up'}">${fmtN(volAnnual,1)}%</div>
      <div class="kpi-sub">日次σ: ${fmtN(std(returns),2)}%</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">最大ドローダウン</div>
      <div class="kpi-value down">-${fmtN(maxDD,1)}%</div>
      <div class="kpi-sub">&nbsp;</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">Sharpe比 (年率)</div>
      <div class="kpi-value ${sharpe>=1?'up':sharpe>=0?'neutral':'down'}">${fmtN(sharpe,2)}</div>
      <div class="kpi-sub">リスク調整リターン</div>
    </div>
    <div class="kpi-card">
      <div class="kpi-label">期間データ数</div>
      <div class="kpi-value neutral">${filteredData.length}</div>
      <div class="kpi-sub">${filteredData[0]?.date} ─</div>
    </div>
  `;

  const maS = parseInt(document.getElementById('maShort').value)||5;
  const maL = parseInt(document.getElementById('maLong').value)||25;
  const maShortArr = ma(closes, maS);
  const maLongArr = ma(closes, maL);
  const lastS = maShortArr[maShortArr.length-1];
  const lastL = maLongArr[maLongArr.length-1];
  const trendBull = lastS > lastL;
  const posRetPct = returns.filter(r=>r>0).length / returns.length * 100;

  document.getElementById('trend-summary').innerHTML = `
    <div class="section-title" style="margin-bottom:10px">トレンドサマリー</div>
    <div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
      <span class="trend-badge ${trendBull?'trend-bull':'trend-bear'}">${trendBull?'▲ BULLISH':'▼ BEARISH'}</span>
      <span style="color:var(--text2);font-size:12px">MA${maS}(${fmtN(lastS,0)}) vs MA${maL}(${fmtN(lastL,0)})</span>
      <span style="color:var(--text2);font-size:12px">上昇日: ${fmtN(posRetPct,1)}%</span>
      <span class="trend-badge ${volAnnual>30?'trend-bear':volAnnual>15?'trend-neutral':'trend-bull'}">ボラ: ${volAnnual>30?'HIGH':volAnnual>15?'MED':'LOW'}</span>
    </div>
    <div style="margin-top:12px">
      <div class="prob-bar-wrap">
        <div class="prob-bar-label"><span>上昇日比率</span><span>${fmtN(posRetPct,1)}%</span></div>
        <div class="prob-bar-bg"><div class="prob-bar-fill" style="width:${posRetPct}%;background:var(--up)"></div></div>
      </div>
      <div class="prob-bar-wrap">
        <div class="prob-bar-label"><span>ボラティリティ水準</span><span>${fmtN(Math.min(volAnnual,80)/80*100,0)}% of scale</span></div>
        <div class="prob-bar-bg"><div class="prob-bar-fill" style="width:${Math.min(volAnnual,80)/80*100}%;background:${volAnnual>30?'var(--down)':volAnnual>15?'var(--warn)':'var(--up)'}"></div></div>
      </div>
    </div>
  `;

  destroyChart('overview-price');
  const ctx1 = document.getElementById('chart-overview-price').getContext('2d');
  const labels = filteredData.map(d=>d.date);
  charts['overview-price'] = new Chart(ctx1, {
    type: 'line',
    data: { labels, datasets: [{ label: '終値', data: closes, borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, fill: { target: 'origin', above: 'rgba(0,229,255,0.05)' } }] },
    options: chartOpts('価格', true)
  });

  destroyChart('overview-dist');
  const bins = buildBins(returns, 0.5);
  const ctx2 = document.getElementById('chart-overview-dist').getContext('2d');
  charts['overview-dist'] = new Chart(ctx2, {
    type: 'bar',
    data: { labels: bins.labels, datasets: [{ label: '頻度', data: bins.counts, backgroundColor: bins.labels.map(l => parseFloat(l) >= 0 ? 'rgba(0,230,118,0.6)' : 'rgba(255,23,68,0.6)'), borderWidth: 0 }] },
    options: chartOpts('日次リターン%')
  });

  document.getElementById('dot-vol').className = `dot ${volAnnual>30?'err':volAnnual>15?'warn':'ok'}`;
  document.getElementById('stat-vol').textContent = `ボラ: ${fmtN(volAnnual,1)}% (年率)`;
}

// ─── PRICE CHART (ローソク足 / ライン切り替え) ──────────────
function rebuildPriceChart() {
  if (!filteredData.length) return;
  const closes = filteredData.map(d => d.close);
  saveSettings();
  buildPriceChart(closes);
}

// ── ローソク足カスタムプラグイン(date adapterなし、純粋Canvas描画)──
const candlestickPlugin = {
  id: 'candlestickDraw',
  afterDatasetsDraw(chart) {
    const ds = chart._ohlcDataset;
    if (!ds) return;
    const ctx2 = chart.ctx;
    const xAxis = chart.scales.x;
    const yAxis = chart.scales.y;
    const n = ds.length;
    if (!n) return;

    // 1本の幅: インデックス間ピクセルの70%
    const barW = Math.max(2, (xAxis.getPixelForValue(1) - xAxis.getPixelForValue(0)) * 0.7);
    const half = barW / 2;

    ctx2.save();
    ds.forEach((d, i) => {
      const x  = xAxis.getPixelForValue(i);
      const yO = yAxis.getPixelForValue(d.o);
      const yC = yAxis.getPixelForValue(d.c);
      const yH = yAxis.getPixelForValue(d.h);
      const yL = yAxis.getPixelForValue(d.l);
      const up = d.c >= d.o;
      const col = up ? '#00e676' : '#ff1744';

      // 高値-安値ライン(ヒゲ)
      ctx2.beginPath();
      ctx2.strokeStyle = col;
      ctx2.lineWidth = 1;
      ctx2.moveTo(x, yH);
      ctx2.lineTo(x, yL);
      ctx2.stroke();

      // 実体
      const top = Math.min(yO, yC);
      const h   = Math.max(1, Math.abs(yC - yO));
      if (up) {
        ctx2.fillStyle = 'rgba(0,230,118,0.85)';
        ctx2.strokeStyle = '#00e676';
      } else {
        ctx2.fillStyle = 'rgba(255,23,68,0.85)';
        ctx2.strokeStyle = '#ff1744';
      }
      ctx2.fillRect(x - half, top, barW, h);
      ctx2.strokeRect(x - half, top, barW, h);
    });
    ctx2.restore();
  }
};
// グローバル登録(二重登録防止)
if (!Chart.registry.plugins.get('candlestickDraw')) {
  Chart.register(candlestickPlugin);
}

function buildPriceChart(closes) {
  const maS = parseInt(document.getElementById('maShort').value)||5;
  const maL = parseInt(document.getElementById('maLong').value)||25;
  const labels = filteredData.map(d=>d.date);
  const volumes = filteredData.map(d=>d.volume||0);
  const chartType = document.getElementById('chartTypeSelect').value;

  destroyChart('price');
  const ctx = document.getElementById('chart-price').getContext('2d');

  const maShortData = ma(closes, maS);
  const maLongData  = ma(closes, maL);

  if (chartType === 'candlestick') {
    const hasOHLC = filteredData.some(d => d.open != null && d.high != null && d.low != null);
    if (!hasOHLC) {
      log('OHLCデータなし。ラインに切り替えます。', 'info');
      document.getElementById('chartTypeSelect').value = 'line';
      buildPriceChart(closes);
      return;
    }
    // OHLCデータをプラグイン用に保存
    const ohlcDs = filteredData.map(d => ({
      o: d.open  != null ? d.open  : d.close,
      h: d.high  != null ? d.high  : d.close,
      l: d.low   != null ? d.low   : d.close,
      c: d.close
    }));

    // y軸範囲をOHLCから計算
    const allH = ohlcDs.map(d=>d.h), allL = ohlcDs.map(d=>d.l);
    const yMin = Math.min(...allL), yMax = Math.max(...allH);
    const pad  = (yMax - yMin) * 0.04;

    // lineデータセットを透明にしてスケールだけ確保し、
    // afterDatasetsDrawでローソク足を描く
    const invisibleClose = {
      label: '終値(内部)',
      data: closes,
      borderColor: 'transparent',
      backgroundColor: 'transparent',
      pointRadius: 0,
      borderWidth: 0,
    };

    const chart = new Chart(ctx, {
      type: 'line',
      data: {
        labels,
        datasets: [
          invisibleClose,
          { label: `MA${maS}`, data: maShortData, borderColor: '#ffea00', borderWidth: 1.2, pointRadius: 0, borderDash: [3,3] },
          { label: `MA${maL}`, data: maLongData,  borderColor: '#ff6b35', borderWidth: 1.2, pointRadius: 0, borderDash: [6,3] },
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: true,
        animation: false,
        interaction: { mode: 'index', intersect: false },
        plugins: {
          legend: {
            labels: {
              color: '#6a8090', font: { family: "'Space Mono'", size: 10 }, boxWidth: 20,
              filter: item => item.text !== '終値(内部)'
            }
          },
          tooltip: {
            backgroundColor: 'rgba(17,20,24,0.95)',
            borderColor: '#242c3a', borderWidth: 1,
            titleColor: '#00e5ff', bodyColor: '#c8d8e8',
            titleFont: { family: "'Space Mono'" }, bodyFont: { family: "'Space Mono'", size: 11 },
            callbacks: {
              label: function(item) {
                if (item.datasetIndex === 0) {
                  const d = ohlcDs[item.dataIndex];
                  if (!d) return '';
                  return `始:${fmtN(d.o,0)} 高:${fmtN(d.h,0)} 安:${fmtN(d.l,0)} 終:${fmtN(d.c,0)}`;
                }
                return item.dataset.label + ': ' + fmtN(item.raw, 0);
              }
            }
          }
        },
        scales: {
          x: { ...axisStyle(), ticks: { ...axisStyle().ticks, maxTicksLimit: 12 } },
          y: {
            ...axisStyle(),
            min: yMin - pad,
            max: yMax + pad,
            title: { display: true, text: '価格', color: '#6a8090', font: { size: 10 } }
          }
        }
      }
    });
    chart._ohlcDataset = ohlcDs;
    charts['price'] = chart;

  } else {
    // ライン表示
    charts['price'] = new Chart(ctx, {
      type: 'line',
      data: {
        labels,
        datasets: [
          { label: '終値', data: closes, borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, order: 1 },
          { label: `MA${maS}`, data: maShortData, borderColor: '#ffea00', borderWidth: 1.2, pointRadius: 0, borderDash: [3,3], order: 2 },
          { label: `MA${maL}`, data: maLongData,  borderColor: '#ff6b35', borderWidth: 1.2, pointRadius: 0, borderDash: [6,3], order: 3 },
        ]
      },
      options: chartOpts('価格')
    });
  }

  destroyChart('volume');
  const ctx2 = document.getElementById('chart-volume').getContext('2d');
  charts['volume'] = new Chart(ctx2, {
    type: 'bar',
    data: { labels, datasets: [{ label: '出来高', data: volumes, backgroundColor: 'rgba(0,229,255,0.3)', borderWidth: 0 }] },
    options: chartOpts('出来高')
  });
}

// ─── TECHNICAL INDICATORS ──────────────────────────────────

// RSI 計算
function calcRSI(closes, period) {
  const rsi = new Array(period).fill(null);
  let avgGain = 0, avgLoss = 0;
  for (let i = 1; i <= period; i++) {
    const diff = closes[i] - closes[i-1];
    if (diff >= 0) avgGain += diff; else avgLoss -= diff;
  }
  avgGain /= period; avgLoss /= period;
  const rs0 = avgLoss === 0 ? 100 : avgGain / avgLoss;
  rsi.push(100 - 100 / (1 + rs0));
  for (let i = period + 1; i < closes.length; i++) {
    const diff = closes[i] - closes[i-1];
    const gain = diff > 0 ? diff : 0;
    const loss = diff < 0 ? -diff : 0;
    avgGain = (avgGain * (period - 1) + gain) / period;
    avgLoss = (avgLoss * (period - 1) + loss) / period;
    const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
    rsi.push(100 - 100 / (1 + rs));
  }
  return rsi;
}

// EMA 計算
function calcEMA(closes, period) {
  const k = 2 / (period + 1);
  const ema = new Array(period - 1).fill(null);
  let prev = avg(closes.slice(0, period));
  ema.push(prev);
  for (let i = period; i < closes.length; i++) {
    prev = closes[i] * k + prev * (1 - k);
    ema.push(prev);
  }
  return ema;
}

// MACD 計算
function calcMACD(closes, fast=12, slow=26, signal=9) {
  const emaFast = calcEMA(closes, fast);
  const emaSlow = calcEMA(closes, slow);
  const macdLine = emaFast.map((v,i) => (v != null && emaSlow[i] != null) ? v - emaSlow[i] : null);
  // シグナル: MACDライン有効部分のみでEMA計算
  const validMacd = macdLine.filter(v => v != null);
  const sigEma = calcEMA(validMacd, signal);
  // シグナルを元の長さに戻す
  const signalLine = new Array(macdLine.length).fill(null);
  let si = 0;
  for (let i = 0; i < macdLine.length; i++) {
    if (macdLine[i] != null) {
      signalLine[i] = sigEma[si]; si++;
    }
  }
  const histogram = macdLine.map((v,i) => (v != null && signalLine[i] != null) ? v - signalLine[i] : null);
  return { macdLine, signalLine, histogram };
}

// ボリンジャーバンド 計算
function calcBB(closes, period, mult) {
  const upper = [], lower = [], mid = [];
  for (let i = 0; i < closes.length; i++) {
    if (i < period - 1) { upper.push(null); lower.push(null); mid.push(null); continue; }
    const slice = closes.slice(i - period + 1, i + 1);
    const m = avg(slice);
    const s = std(slice);
    mid.push(m);
    upper.push(m + mult * s);
    lower.push(m - mult * s);
  }
  return { upper, lower, mid };
}

function buildTechnicalTab(closes) {
  const rsiPeriod = parseInt(document.getElementById('rsiPeriod').value) || 14;
  const bbPeriod  = parseInt(document.getElementById('bbPeriod').value) || 20;
  const bbMult    = parseFloat(document.getElementById('bbMult').value) || 2;
  const labels    = filteredData.map(d=>d.date);

  document.getElementById('rsi-period-label').textContent = rsiPeriod;

  // ── RSI ──
  const rsiVals = calcRSI(closes, rsiPeriod);
  const lastRSI = rsiVals[rsiVals.length - 1];
  const rsiSig = lastRSI >= 70 ? 'sell' : lastRSI <= 30 ? 'buy' : 'neut';
  const rsiLabel = lastRSI >= 70 ? '買われ過ぎ (SELL)' : lastRSI <= 30 ? '売られ過ぎ (BUY)' : 'ニュートラル';

  // ── MACD ──
  const { macdLine, signalLine, histogram } = calcMACD(closes);
  const lastMACD = macdLine[macdLine.length-1];
  const lastSig  = signalLine[signalLine.length-1];
  const lastHist = histogram[histogram.length-1];
  const macdSig = (lastMACD != null && lastSig != null) ? (lastMACD > lastSig ? 'buy' : 'sell') : 'neut';
  const macdLabel = macdSig === 'buy' ? 'MACDラインがシグナル上 (BULL)' : macdSig === 'sell' ? 'MACDラインがシグナル下 (BEAR)' : '─';

  // ── BB ──
  const { upper, lower, mid } = calcBB(closes, bbPeriod, bbMult);
  const lastClose = closes[closes.length-1];
  const lastUpper = upper[upper.length-1];
  const lastLower = lower[lower.length-1];
  const bbSig = lastClose >= lastUpper ? 'sell' : lastClose <= lastLower ? 'buy' : 'neut';
  const bbLabel = bbSig === 'sell' ? 'アッパーバンドタッチ (過買い)' : bbSig === 'buy' ? 'ロワーバンドタッチ (過売り)' : '中間帯';

  // ─ シグナルサマリー ─
  const sigMap = { buy:'<span class="sig-buy">BUY</span>', sell:'<span class="sig-sell">SELL</span>', neut:'<span class="sig-neut">NEUTRAL</span>' };
  const cls    = { buy:'buy', sell:'sell', neut:'neut' };
  document.getElementById('tech-signal-area').innerHTML = `
    <div class="section-title" style="margin-bottom:10px">テクニカルシグナル (最終日)</div>
    <div class="tech-row ${cls[rsiSig]}">
      <div style="font-family:'Space Mono',monospace;font-size:12px;width:80px;color:var(--text2)">RSI</div>
      ${sigMap[rsiSig]}
      <div style="font-size:11px;color:var(--text2)">${rsiLabel} &nbsp; 値: ${fmtN(lastRSI,1)}</div>
    </div>
    <div class="tech-row ${cls[macdSig]}">
      <div style="font-family:'Space Mono',monospace;font-size:12px;width:80px;color:var(--text2)">MACD</div>
      ${sigMap[macdSig]}
      <div style="font-size:11px;color:var(--text2)">${macdLabel} &nbsp; ヒスト: ${fmtN(lastHist,3)}</div>
    </div>
    <div class="tech-row ${cls[bbSig]}">
      <div style="font-family:'Space Mono',monospace;font-size:12px;width:80px;color:var(--text2)">BB</div>
      ${sigMap[bbSig]}
      <div style="font-size:11px;color:var(--text2)">${bbLabel} &nbsp; 上: ${fmtN(lastUpper,0)} / 下: ${fmtN(lastLower,0)}</div>
    </div>
  `;

  // ── BB チャート ──
  destroyChart('bb');
  const ctxBB = document.getElementById('chart-bb').getContext('2d');
  charts['bb'] = new Chart(ctxBB, {
    type: 'line',
    data: {
      labels,
      datasets: [
        { label: '終値',      data: closes, borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, order: 1 },
        { label: `BB上(${bbMult}σ)`, data: upper,  borderColor: 'rgba(255,107,53,0.8)', borderWidth: 1, borderDash:[4,3], pointRadius: 0, fill: '+1', backgroundColor: 'rgba(255,107,53,0.05)' },
        { label: `BB中(MA${bbPeriod})`, data: mid,  borderColor: 'rgba(255,234,0,0.6)', borderWidth: 1, borderDash:[2,2], pointRadius: 0 },
        { label: `BB下(${bbMult}σ)`, data: lower, borderColor: 'rgba(255,107,53,0.8)', borderWidth: 1, borderDash:[4,3], pointRadius: 0 },
      ]
    },
    options: chartOpts('価格')
  });

  // ── RSI チャート ──
  destroyChart('rsi');
  const ctxRSI = document.getElementById('chart-rsi').getContext('2d');
  charts['rsi'] = new Chart(ctxRSI, {
    type: 'line',
    data: {
      labels,
      datasets: [
        { label: `RSI(${rsiPeriod})`, data: rsiVals, borderColor: '#c8a0f8', borderWidth: 1.5, pointRadius: 0, fill: false },
        { label: '買われ過ぎ(70)', data: new Array(labels.length).fill(70), borderColor: 'rgba(255,23,68,0.5)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
        { label: '売られ過ぎ(30)', data: new Array(labels.length).fill(30), borderColor: 'rgba(0,230,118,0.5)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
      ]
    },
    options: {
      ...chartOpts('RSI'),
      scales: {
        x: axisStyle(),
        y: { ...axisStyle(), min: 0, max: 100, title: { display: true, text: 'RSI', color: '#6a8090', font: { size: 10 } } }
      }
    }
  });

  // ── MACD チャート (混合型) ──
  destroyChart('macd');
  const ctxMACD = document.getElementById('chart-macd').getContext('2d');
  charts['macd'] = new Chart(ctxMACD, {
    type: 'bar',
    data: {
      labels,
      datasets: [
        { label: 'ヒストグラム', type: 'bar', data: histogram,   backgroundColor: histogram.map(v => v==null?'transparent':v>=0?'rgba(0,230,118,0.5)':'rgba(255,23,68,0.5)'), borderWidth: 0, order: 3 },
        { label: 'MACDライン',  type: 'line', data: macdLine,    borderColor: '#00e5ff', borderWidth: 1.5, pointRadius: 0, order: 1 },
        { label: 'シグナル',    type: 'line', data: signalLine,  borderColor: '#ff6b35', borderWidth: 1.2, borderDash:[4,3], pointRadius: 0, order: 2 },
      ]
    },
    options: chartOpts('MACD')
  });
}

// ─── VOLATILITY ────────────────────────────────────────────
function buildVolatilityTab(closes, returns) {
  const volW = parseInt(document.getElementById('volWindow').value)||20;
  const labels = filteredData.map(d=>d.date).slice(volW);

  const rollingVol = [];
  for (let i = volW; i <= returns.length; i++) {
    rollingVol.push(std(returns.slice(i-volW, i)) * Math.sqrt(252));
  }

  destroyChart('vol');
  const ctx = document.getElementById('chart-vol').getContext('2d');
  const avgVol = avg(rollingVol);
  charts['vol'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [
        { label: `ローリングVol(${volW}日)`, data: rollingVol, borderColor: '#ff6b35', borderWidth: 1.5, pointRadius: 0, fill: { target: 'origin', above: 'rgba(255,107,53,0.08)' } },
        { label: '平均', data: new Array(labels.length).fill(avgVol), borderColor: '#ffea00', borderWidth: 1, borderDash: [4,4], pointRadius: 0 },
      ]
    },
    options: chartOpts('ボラ(年率%)')
  });

  const highThresh = avgVol * 1.5;
  const lowThresh = avgVol * 0.6;
  const highVol = rollingVol.filter(v => v > highThresh);
  const lowVol = rollingVol.filter(v => v < lowThresh);
  const medVol = rollingVol.filter(v => v >= lowThresh && v <= highThresh);

  document.getElementById('vol-classification').innerHTML = `
    <div class="vol-indicator high"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--down)">高ボラ期</div><div style="font-size:10px;color:var(--text2)">>${fmtN(highThresh,1)}% 年率</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--down)">${highVol.length}日 (${fmtN(highVol.length/rollingVol.length*100,0)}%)</div></div>
    <div class="vol-indicator med"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--warn)">中ボラ期</div><div style="font-size:10px;color:var(--text2)">${fmtN(lowThresh,1)}─${fmtN(highThresh,1)}%</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--warn)">${medVol.length}日 (${fmtN(medVol.length/rollingVol.length*100,0)}%)</div></div>
    <div class="vol-indicator low"><div><div style="font-family:'Space Mono',monospace;font-size:13px;color:var(--up)">低ボラ期</div><div style="font-size:10px;color:var(--text2)"><${fmtN(lowThresh,1)}%</div></div><div style="margin-left:auto;font-family:'Space Mono',monospace;font-size:16px;color:var(--up)">${lowVol.length}日 (${fmtN(lowVol.length/rollingVol.length*100,0)}%)</div></div>
    <div style="margin-top:8px;font-size:11px;color:var(--text2)">
      高ボラ期 平均日次リターン: <span style="color:var(--accent)">${fmtPct(getReturnsByVolRegime(returns, rollingVol, volW, 'high', highThresh))}</span><br>
      低ボラ期 平均日次リターン: <span style="color:var(--accent)">${fmtPct(getReturnsByVolRegime(returns, rollingVol, volW, 'low', lowThresh))}</span>
    </div>
  `;

  const hR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'high', highThresh);
  const lR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'low', lowThresh);
  const mR = getReturnsArrByVolRegime(returns, rollingVol, volW, 'med', null, lowThresh, highThresh);
  const binsH = buildBins(hR.length > 0 ? hR : [0], 0.5);
  const binsL = buildBins(lR.length > 0 ? lR : [0], 0.5);
  const allLabels = [...new Set([...binsH.labels, ...binsL.labels])].sort((a,b)=>parseFloat(a)-parseFloat(b));

  destroyChart('vol-return');
  const ctx2 = document.getElementById('chart-vol-return').getContext('2d');
  charts['vol-return'] = new Chart(ctx2, {
    type: 'bar',
    data: {
      labels: allLabels,
      datasets: [
        { label: '高ボラ期', data: allLabels.map(l => (binsH.map[l]||0)), backgroundColor: 'rgba(255,23,68,0.5)', borderWidth: 0 },
        { label: '低ボラ期', data: allLabels.map(l => (binsL.map[l]||0)), backgroundColor: 'rgba(0,230,118,0.5)', borderWidth: 0 },
      ]
    },
    options: { ...chartOpts('頻度'), scales: { x: { stacked: false, ...axisStyle() }, y: { ...axisStyle() } } }
  });
}

function getReturnsByVolRegime(returns, rollingVol, volW, regime, thresh, low, high) {
  const arr = getReturnsArrByVolRegime(returns, rollingVol, volW, regime, thresh, low, high);
  return arr.length ? avg(arr) : NaN;
}
function getReturnsArrByVolRegime(returns, rollingVol, volW, regime, thresh, low, high) {
  const arr = [];
  for (let i = 0; i < rollingVol.length; i++) {
    const v = rollingVol[i];
    const ri = i + volW;
    if (ri >= returns.length) break;
    let match = false;
    if (regime === 'high') match = v > thresh;
    else if (regime === 'low') match = v < thresh;
    else match = v >= low && v <= high;
    if (match) arr.push(returns[ri]);
  }
  return arr;
}

// ─── DISTRIBUTION ──────────────────────────────────────────
function buildDistribution(returns) {
  const binSize = parseFloat(document.getElementById('binSize').value)||1;
  const bins = buildBins(returns, binSize);

  destroyChart('hist');
  const ctx1 = document.getElementById('chart-hist').getContext('2d');
  charts['hist'] = new Chart(ctx1, {
    type: 'bar',
    data: {
      labels: bins.labels,
      datasets: [{
        label: '頻度',
        data: bins.counts,
        backgroundColor: bins.labels.map(l => parseFloat(l) >= 0 ? 'rgba(0,230,118,0.6)' : 'rgba(255,23,68,0.6)'),
        borderWidth: 0
      }]
    },
    options: chartOpts('日次リターン%')
  });

  const lags = 20;
  const acfVals = [];
  const m = avg(returns);
  const denom = returns.reduce((a,r)=>a+(r-m)**2, 0);
  for (let k=0; k<=lags; k++) {
    let num = 0;
    for (let i=k; i<returns.length; i++) num += (returns[i]-m)*(returns[i-k]-m);
    acfVals.push(num/denom);
  }
  destroyChart('acf');
  const ctx2 = document.getElementById('chart-acf').getContext('2d');
  const confInterval = 1.96 / Math.sqrt(returns.length);
  charts['acf'] = new Chart(ctx2, {
    type: 'bar',
    data: {
      labels: acfVals.map((_,i) => `L${i}`),
      datasets: [
        { label: 'ACF', data: acfVals, backgroundColor: acfVals.map((v,i) => i===0?'transparent':Math.abs(v)>confInterval?'rgba(255,234,0,0.7)':'rgba(0,229,255,0.4)'), borderWidth: 0 },
        { label: '+95%CI', data: new Array(lags+1).fill(confInterval), type: 'line', borderColor: 'rgba(255,107,53,0.6)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
        { label: '-95%CI', data: new Array(lags+1).fill(-confInterval), type: 'line', borderColor: 'rgba(255,107,53,0.6)', borderDash:[4,4], borderWidth:1, pointRadius:0 },
      ]
    },
    options: chartOpts('自己相関係数')
  });

  const monthly = {};
  filteredData.forEach((d, i) => {
    if (i === 0) return;
    const mo = d.date.slice(0,7);
    if (!monthly[mo]) monthly[mo] = [];
    const r = (filteredData[i].close - filteredData[i-1].close) / filteredData[i-1].close * 100;
    monthly[mo].push(r);
  });
  const mLabels = Object.keys(monthly).sort();
  const mVals = mLabels.map(k => avg(monthly[k]));
  destroyChart('monthly');
  const ctx3 = document.getElementById('chart-monthly').getContext('2d');
  charts['monthly'] = new Chart(ctx3, {
    type: 'bar',
    data: {
      labels: mLabels,
      datasets: [{ label: '月平均リターン%', data: mVals, backgroundColor: mVals.map(v => v>=0?'rgba(0,230,118,0.6)':'rgba(255,23,68,0.6)'), borderWidth: 0 }]
    },
    options: chartOpts('%')
  });
}

// ─── PROBABILITY ───────────────────────────────────────────
function buildProbTable() {
  if (!filteredData.length) return;
  const closes = filteredData.map(d => d.close);
  const last = closes[closes.length - 1];
  const refInput = document.getElementById('refPrice').value;
  const ref = refInput ? parseFloat(refInput) : last;
  const tol = parseFloat(document.getElementById('tolerance').value) || 2;

  const returns = closes.slice(1).map((c,i)=>(c-closes[i])/closes[i]*100);

  const ranges = [-10, -5, -3, -2, -1, -0.5, 0, 0.5, 1, 2, 3, 5, 10];
  const tableRows = [];
  for (let i=0; i<ranges.length-1; i++) {
    const lo = ranges[i], hi = ranges[i+1];
    const cnt = returns.filter(r => r >= lo && r < hi).length;
    const prob = cnt / returns.length * 100;
    tableRows.push({ range: `${fmtPct(lo)} ~ ${fmtPct(hi)}`, cnt, prob });
  }

  const nearCount = closes.filter(c => Math.abs(c - ref) / ref * 100 <= tol).length;
  const nearProb = nearCount / closes.length * 100;

  document.getElementById('prob-table-area').innerHTML = `
    <div style="margin-bottom:12px;padding:10px;background:var(--bg);border-radius:4px;border:1px solid var(--border)">
      <span style="font-size:11px;color:var(--text2)">基準価格 </span><span style="font-family:'Space Mono',monospace;color:var(--accent)">${fmtN(ref,0)}</span>
      <span style="font-size:11px;color:var(--text2);margin-left:16px">±${tol}% 範囲内の出現確率: </span>
      <span style="font-family:'Space Mono',monospace;font-size:16px;color:${nearProb>30?'var(--up)':'var(--accent)'}">${fmtN(nearProb,1)}%</span>
      <span style="font-size:10px;color:var(--text2);margin-left:8px">(${nearCount}/${closes.length}日)</span>
    </div>
    <table class="dist-table">
      <thead><tr><th>翌日リターン帯</th><th>日数</th><th>確率</th><th>バー</th></tr></thead>
      <tbody>
        ${tableRows.map(r => `
          <tr>
            <td>${r.range}</td>
            <td>${r.cnt}</td>
            <td style="color:${r.prob>15?'var(--accent)':'var(--text)'}">${fmtN(r.prob,1)}%</td>
            <td style="width:120px"><div style="height:6px;background:var(--bg);border-radius:2px;overflow:hidden"><div style="width:${Math.min(r.prob*3,100)}%;height:100%;background:var(--accent);border-radius:2px"></div></div></td>
          </tr>
        `).join('')}
      </tbody>
    </table>
  `;

  buildKDE(returns);
}

function buildKDE(returns) {
  const min = Math.min(...returns) - 1;
  const max = Math.max(...returns) + 1;
  const step = 0.2;
  const xVals = [];
  for (let x = min; x <= max; x += step) xVals.push(x);

  const h = 1.06 * std(returns) * Math.pow(returns.length, -0.2);
  const kdeVals = xVals.map(x => {
    return returns.reduce((sum, r) => {
      const u = (x - r) / h;
      return sum + Math.exp(-0.5 * u * u) / (Math.sqrt(2 * Math.PI));
    }, 0) / (returns.length * h);
  });

  const m = avg(returns), s = std(returns);
  const normalVals = xVals.map(x => Math.exp(-0.5*((x-m)/s)**2) / (s * Math.sqrt(2*Math.PI)));

  destroyChart('kde');
  const ctx = document.getElementById('chart-kde').getContext('2d');
  charts['kde'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels: xVals.map(v => fmtN(v,1)),
      datasets: [
        { label: '実績KDE', data: kdeVals, borderColor: '#00e5ff', borderWidth: 2, pointRadius: 0, fill: { target: 'origin', above: 'rgba(0,229,255,0.08)' } },
        { label: '正規分布', data: normalVals, borderColor: '#ff6b35', borderWidth: 1.5, borderDash: [6,3], pointRadius: 0 },
      ]
    },
    options: chartOpts('確率密度')
  });
}

// ─── Helpers ───────────────────────────────────────────────
function calcMaxDD(closes) {
  let peak = closes[0], maxDD = 0;
  for (const c of closes) {
    if (c > peak) peak = c;
    const dd = (peak - c) / peak * 100;
    if (dd > maxDD) maxDD = dd;
  }
  return maxDD;
}
function buildBins(returns, step) {
  if (!returns.length) return { labels:[], counts:[], map:{} };
  const min = Math.floor(Math.min(...returns)/step)*step;
  const max = Math.ceil(Math.max(...returns)/step)*step;
  const labels = [], counts = [], map = {};
  for (let v = min; v <= max; v += step) {
    const l = fmtN(v,1);
    labels.push(l);
    const cnt = returns.filter(r => r >= v && r < v+step).length;
    counts.push(cnt);
    map[l] = cnt;
  }
  return { labels, counts, map };
}

function axisStyle() {
  return {
    ticks: { color: '#6a8090', font: { family: "'Space Mono'", size: 9 } },
    grid: { color: 'rgba(36,44,58,0.8)' },
    border: { color: '#242c3a' }
  };
}
function chartOpts(yLabel, tension=false) {
  return {
    responsive: true,
    maintainAspectRatio: true,
    interaction: { mode: 'index', intersect: false },
    plugins: {
      legend: { labels: { color: '#6a8090', font: { family: "'Space Mono'", size: 10 }, boxWidth: 20 } },
      tooltip: {
        backgroundColor: 'rgba(17,20,24,0.95)',
        borderColor: '#242c3a', borderWidth: 1,
        titleColor: '#00e5ff', bodyColor: '#c8d8e8',
        titleFont: { family: "'Space Mono'" }, bodyFont: { family: "'Space Mono'", size: 11 }
      }
    },
    elements: { line: { tension: tension ? 0.2 : 0 } },
    scales: { x: axisStyle(), y: { ...axisStyle(), title: { display: !!yLabel, text: yLabel, color: '#6a8090', font: { size: 10 } } } }
  };
}

// ─── Tab switch ────────────────────────────────────────────
function switchTab(name, el) {
  currentTab = name;
  document.getElementById('main-tab-bar').querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
  el.classList.add('active');
  document.getElementById('tab-' + name).classList.add('active');
}

// ─── Export: PNG (単一グラフ) ──────────────────────────────
function exportChartPNG(canvasId, filename) {
  const canvas = document.getElementById(canvasId);
  if (!canvas) { log('グラフが見つかりません', 'err'); return; }
  // 背景色を黒に設定してPNGを生成
  const offscreen = document.createElement('canvas');
  offscreen.width  = canvas.width;
  offscreen.height = canvas.height;
  const octx = offscreen.getContext('2d');
  octx.fillStyle = '#0a0c10';
  octx.fillRect(0, 0, offscreen.width, offscreen.height);
  octx.drawImage(canvas, 0, 0);

  const link = document.createElement('a');
  link.href     = offscreen.toDataURL('image/png');
  link.download = `quantview_${filename}_${new Date().toISOString().slice(0,10)}.png`;
  link.click();
  log(`PNG保存: ${filename}`, 'ok');
}

function exportCurrentChartPNG() {
  // 現在のタブで最初に見つかるcanvasを保存
  const tab = document.getElementById('tab-' + currentTab);
  if (!tab) { log('タブが開かれていません', 'err'); return; }
  const canvas = tab.querySelector('canvas');
  if (!canvas) { log('グラフが見つかりません', 'err'); return; }
  exportChartPNG(canvas.id, currentTab + '-chart');
}

// ─── Export: PDF (全グラフ) ────────────────────────────────
async function exportAllPDF() {
  if (!filteredData.length) { log('データを読み込んでください', 'err'); return; }

  // jsPDFが読み込まれているか確認
  if (typeof window.jspdf === 'undefined') {
    log('PDF生成ライブラリ読み込み中...', 'info');
    await loadJsPDF();
  }

  const { jsPDF } = window.jspdf;
  const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
  const W = 297, H = 210;
  const margin = 12;

  // タイトルページ
  doc.setFillColor(10, 12, 16);
  doc.rect(0, 0, W, H, 'F');
  doc.setTextColor(0, 229, 255);
  doc.setFontSize(32);
  doc.setFont('helvetica', 'bold');
  doc.text('QUANTVIEW', W/2, 70, { align:'center' });
  doc.setTextColor(200, 216, 232);
  doc.setFontSize(16);
  doc.setFont('helvetica', 'normal');
  doc.text('Stock Trend Analysis Report', W/2, 85, { align:'center' });
  doc.setTextColor(106, 128, 144);
  doc.setFontSize(11);
  const sym = document.getElementById('stat-symbol').textContent;
  const rng = document.getElementById('stat-range').textContent;
  doc.text(`Symbol: ${sym}`, W/2, 100, { align:'center' });
  doc.text(rng, W/2, 110, { align:'center' });
  doc.text(`Generated: ${new Date().toLocaleString('ja-JP')}`, W/2, 120, { align:'center' });

  // 各グラフをA4ページに追加
  const chartEntries = [
    { id:'chart-overview-price', title:'価格推移' },
    { id:'chart-overview-dist',  title:'日次リターン分布' },
    { id:'chart-price',          title:'価格チャート' },
    { id:'chart-bb',             title:'ボリンジャーバンド' },
    { id:'chart-rsi',            title:'RSI' },
    { id:'chart-macd',           title:'MACD' },
    { id:'chart-vol',            title:'ローリングボラティリティ' },
    { id:'chart-vol-return',     title:'ボラ水準別リターン分布' },
    { id:'chart-hist',           title:'ヒストグラム' },
    { id:'chart-acf',            title:'自己相関(ACF)' },
    { id:'chart-monthly',        title:'月別平均リターン' },
    { id:'chart-kde',            title:'確率密度推定(KDE)' },
  ];

  // 2グラフずつ1ページにレイアウト
  for (let i = 0; i < chartEntries.length; i += 2) {
    doc.addPage();
    doc.setFillColor(10, 12, 16);
    doc.rect(0, 0, W, H, 'F');

    for (let j = 0; j < 2; j++) {
      const entry = chartEntries[i + j];
      if (!entry) break;
      const canvas = document.getElementById(entry.id);
      if (!canvas) continue;

      // オフスクリーンで背景付きPNG生成
      const off = document.createElement('canvas');
      off.width  = canvas.width;
      off.height = canvas.height;
      const octx = off.getContext('2d');
      octx.fillStyle = '#1a1f28';
      octx.fillRect(0, 0, off.width, off.height);
      octx.drawImage(canvas, 0, 0);
      const imgData = off.toDataURL('image/jpeg', 0.92);

      // ページ内の位置 (左右2列)
      const col  = j;
      const x    = margin + col * (W/2 - margin * 0.5);
      const imgW = W/2 - margin * 1.5;
      const imgH = H - margin * 3.5;
      const y    = margin * 2.2;

      // タイトル
      doc.setTextColor(106, 128, 144);
      doc.setFontSize(9);
      doc.setFont('helvetica', 'normal');
      doc.text(entry.title, x, margin * 1.6);

      // 画像 (アスペクト比を維持)
      const aspect = canvas.height / canvas.width;
      const actualH = Math.min(imgH, imgW * aspect);
      doc.addImage(imgData, 'JPEG', x, y, imgW, actualH);
    }
  }

  const filename = `quantview_report_${new Date().toISOString().slice(0,10)}.pdf`;
  doc.save(filename);
  log(`PDF保存完了: ${filename}`, 'ok');
}

// jsPDFの動的読み込み(フォールバック)
function loadJsPDF() {
  return new Promise((resolve, reject) => {
    if (typeof window.jspdf !== 'undefined') { resolve(); return; }
    const script = document.createElement('script');
    script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
    script.onload = resolve;
    script.onerror = () => reject(new Error('jsPDF読み込み失敗'));
    document.head.appendChild(script);
  });
}

// ─── Demo / No data state + 初期化 ────────────────────────
log('データを取得またはCSVをロードしてください', 'info');

// DOM読み込み後に設定復元 & イベントバインド
document.addEventListener('DOMContentLoaded', () => {
  loadSettings();
  bindSettingsAutoSave();
});
</script>

<!-- CORS説明モーダル -->
<div id="cors-modal" style="display:none;position:fixed;inset:0;background:rgba(10,12,16,0.92);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(6px)">
  <div style="background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:28px;max-width:560px;width:90%;position:relative">
    <div style="font-family:'Space Mono',monospace;font-size:16px;color:var(--accent);margin-bottom:16px">⚠ CORS制限について</div>
    <p style="color:var(--text);line-height:1.8;font-size:13px">
      <code style="background:var(--bg);padding:2px 6px;border-radius:3px;color:var(--accent)">file://</code> でHTMLを直接開いた場合、<br>
      ブラウザのセキュリティ制限により外部APIへのアクセスがブロックされます。
    </p>
    <div style="margin:16px 0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
      <!-- タブ1: Pythonサーバー -->
      <div style="background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border)">
        <div style="font-family:'Space Mono',monospace;font-size:11px;color:var(--accent2);margin-bottom:8px">▶ 方法1: Pythonサーバーで開く (推奨)</div>
        <div style="font-size:11px;color:var(--text2);margin-bottom:8px">HTMLと同じフォルダで下記を実行:</div>
        <pre style="background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:10px;font-family:'Space Mono',monospace;font-size:11px;color:var(--up);overflow-x:auto">python server.py</pre>
        <div style="font-size:10px;color:var(--text2);margin-top:6px">→ ブラウザで <code style="color:var(--accent)">http://localhost:8765</code> を開いてください</div>
      </div>
      <!-- タブ2: CSV -->
      <div style="background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border)">
        <div style="font-family:'Space Mono',monospace;font-size:11px;color:var(--accent2);margin-bottom:8px">▶ 方法2: CSVファイルをアップロード</div>
        <div style="font-size:11px;color:var(--text2);line-height:1.8">
          CSVの入手先:<br>
          ・<a href="https://stooq.com" target="_blank" style="color:var(--accent)">stooq.com</a> → 銘柄検索 → Data → Download CSV<br>
          ・Yahoo Finance → 銘柄ページ → 履歴データ → ダウンロード<br>
          ・<a href="https://finance.yahoo.com/quote/7203.T/history" target="_blank" style="color:var(--accent)">Yahoo Finance 日本株例</a>
        </div>
      </div>
      <!-- タブ3: デモ -->
      <div style="background:var(--bg2);padding:12px 16px">
        <div style="font-family:'Space Mono',monospace;font-size:11px;color:var(--accent2);margin-bottom:8px">▶ 方法3: デモデータで動作確認</div>
        <div style="font-size:11px;color:var(--text2);margin-bottom:10px">架空の株価データ(ランダムウォーク)で全機能を試せます</div>
        <button class="btn btn-primary" onclick="loadDemoFromModal()" style="max-width:220px">
          📊 デモデータで起動 (<span id="cors-sym">─</span>)
        </button>
      </div>
    </div>
    <div style="display:flex;gap:8px;justify-content:flex-end">
      <button class="btn btn-secondary btn-sm" onclick="closeCorsModal()">閉じる</button>
    </div>
  </div>
</div>

</body>
</html>

・ダウンロードされる方はこちら。↓

ここから先は

0字

¥ 300

この記事が気に入ったらチップで応援してみませんか?

購入者のコメント

コメントするには、 ログイン または 会員登録 をお願いします。
株価分析ツール「QWANT VIEW」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1