モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 

株価 LSTM 予測システム(デモ版)

株価CSVファイルをご用意ください。形式は「"日付(yyyy/mm/dd)","終値","始値","高値","安値"」です。

機械学習を行い、翌営業日予測終値を出力します。

デモ版では、パラメータの変更は不可で、10エポックの実行のみとしています。

株価 LSTM 予測(デモ版)

BACKEND: —
📂
CSVファイルをドロップまたはクリックして選択
形式: "日付(yyyy/mm/dd)","終値","始値","高値","安値"
[SYSTEM] CSVファイルを読み込んでください...

1. 入力項目(設定パラメータ)

モデルの学習構造や計算負荷を制御する、ユーザー入力可能な変数群です。

項目名 役割と解説
ウィンドウサイズ 過去何日分のデータを「1つの塊」として学習に使うか。20日なら直近約1ヶ月の動きを参考にします。
LSTMユニット数 ニューラルネットワークの「脳の大きさ」に相当。第1層、第2層の2段階でパターンを抽出します。
Dropout率 学習中の「ノードの間引き」率。過学習(過去データにのみ特化してしまう現象)を防ぎます。
エポック数 同じデータセットを何回繰り返し学習させるか。精度と時間のトレードオフを決定します。
訓練データ比率 全データのうち、学習に使う割合(例:80%)。残りの20%は予測が当たっているかの検証に使用。
学習率 計算ごとに重みを調整する歩幅。小さすぎると進まず、大きすぎると最適解を通り過ぎます。

2. 出力項目(評価指標と可視化)

学習の結果、どれほどの精度が得られたか、および将来の予測値を出力します。

■ 統計ステータス

  • BEST LOSS: 誤差(MSE)の最小値。学習が順調ならエポックごとに低下します。
  • TEST MAE (円): 平均絶対誤差。実測値と予測値が平均して何円ズレているかの指標。
  • DIRECTION ACC.: トレンド的中率。価格の「上下方向」が一致した確率をパーセントで表示。

■ グラフ出力

  • 学習曲線: Loss(損失)とMAE(誤差)の推移。モデルが学習できているかを視覚化します。
  • 予測 vs 実測: テスト期間における答え合わせ。線の重なり具合でモデルの性能を判断。
  • 翌日予測値: 最も重要な出力。直近データから導き出した「次の営業日の期待値」です。

3. システムの特筆すべきロジック

ブラウザの負荷を軽減するための独自ロジックが組み込まれています。

  • 自動バックエンド選択: バッチサイズやユニット数が小さい場合は CPU、負荷が高い場合は WebGL (GPU) を自動選択し、クラッシュを防ぎます。
  • 正規化処理: 価格データを 0〜1 にスケーリングして学習させ、出力時に元の円単位へ復元(Unscale)しています。

プログラムソース


<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>株価 LSTM 予測システム</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tensorflow/4.10.0/tf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<script>
// ─── バックエンド自動選択(学習開始前に呼ぶ)─────────────────
// バッチサイズ < 32 かつ LSTMユニット最大値 < 128 の場合は CPU を使用
async function selectBackend(batchSz, units1, units2) {
  const useCPU = batchSz < 32 && Math.max(units1, units2) < 128;
  const backend = useCPU ? 'cpu' : 'webgl';
  await tf.setBackend(backend);
  await tf.ready();
  return backend;
}
</script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Zen+Kaku+Gothic+New:wght@300;400;700&family=Share+Tech+Mono&display=swap');

  :root {
    --bg: #08090d;
    --surface: #0f1118;
    --surface2: #161923;
    --border: #1e2535;
    --accent: #00d4ff;
    --accent2: #ff6b35;
    --accent3: #39ff14;
    --text: #c8d6e8;
    --text-dim: #4a5a72;
    --font-main: 'Zen Kaku Gothic New', sans-serif;
    --font-mono: 'Share Tech Mono', monospace;
  }

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-main);
    min-height: 100vh;
    overflow-x: hidden;
  }

  /* Grid background */
  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
      linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
    background-size: 40px 40px;
    pointer-events: none;
    z-index: 0;
  }

  .container {
    position: relative;
    z-index: 1;
    max-width: 1100px;
    margin: 0 auto;
    padding: 32px 24px;
  }

  header {
    display: flex;
    align-items: baseline;
    gap: 16px;
    margin-bottom: 8px;
  }

  h1 {
    font-size: 2rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    color: #fff;
  }

  h1 span { color: var(--accent); }

  .subtitle {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-dim);
    letter-spacing: 0.15em;
    margin-bottom: 32px;
    display: flex;
    align-items: center;
    gap: 14px;
    flex-wrap: wrap;
  }

  .backend-badge {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    padding: 2px 10px;
    border-radius: 2px;
    letter-spacing: 0.1em;
    border: 1px solid;
  }
  .backend-badge.cpu   { color: #ff6b35; border-color: rgba(255,107,53,0.4); background: rgba(255,107,53,0.08); }
  .backend-badge.webgl { color: #39ff14; border-color: rgba(57,255,20,0.4);  background: rgba(57,255,20,0.08); }

  /* Upload zone */
  .upload-zone {
    border: 1.5px dashed var(--border);
    border-radius: 4px;
    padding: 36px;
    text-align: center;
    cursor: pointer;
    transition: border-color 0.2s, background 0.2s;
    margin-bottom: 24px;
    position: relative;
  }

  .upload-zone:hover, .upload-zone.drag {
    border-color: var(--accent);
    background: rgba(0,212,255,0.04);
  }

  .upload-zone input[type=file] {
    position: absolute;
    inset: 0;
    opacity: 0;
    cursor: pointer;
    width: 100%;
    height: 100%;
  }

  .upload-icon {
    font-size: 2rem;
    margin-bottom: 8px;
    color: var(--accent);
  }

  .upload-label {
    font-size: 0.95rem;
    color: var(--text-dim);
  }

  .upload-label strong { color: var(--text); }

  /* Config panel */
  .config-panel {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: 16px;
    margin-bottom: 24px;
  }

  .config-item label {
    display: block;
    font-family: var(--font-mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    letter-spacing: 0.12em;
    margin-bottom: 6px;
  }

  .config-item input, .config-item select {
    width: 100%;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 3px;
    color: var(--text);
    font-family: var(--font-mono);
    font-size: 0.9rem;
    padding: 8px 12px;
    outline: none;
    transition: border-color 0.2s;
  }

  .config-item input:focus, .config-item select:focus {
    border-color: var(--accent);
  }

  /* Button */
  .btn {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    background: transparent;
    border: 1.5px solid var(--accent);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: 0.85rem;
    letter-spacing: 0.1em;
    padding: 10px 28px;
    border-radius: 3px;
    cursor: pointer;
    transition: background 0.2s, color 0.2s, box-shadow 0.2s;
    margin-bottom: 24px;
  }

  .btn:hover:not(:disabled) {
    background: var(--accent);
    color: #000;
    box-shadow: 0 0 20px rgba(0,212,255,0.3);
  }

  .btn:disabled { opacity: 0.35; cursor: not-allowed; }

  /* Log */
  .log-box {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 16px;
    font-family: var(--font-mono);
    font-size: 0.78rem;
    color: var(--text-dim);
    height: 130px;
    overflow-y: auto;
    margin-bottom: 24px;
    line-height: 1.7;
  }

  .log-box .ok   { color: var(--accent3); }
  .log-box .warn { color: var(--accent2); }
  .log-box .info { color: var(--accent); }

  /* Stats row */
  .stats-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: 12px;
    margin-bottom: 28px;
  }

  .stat-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 16px;
  }

  .stat-card .stat-label {
    font-family: var(--font-mono);
    font-size: 0.65rem;
    color: var(--text-dim);
    letter-spacing: 0.12em;
    margin-bottom: 6px;
  }

  .stat-card .stat-value {
    font-family: var(--font-mono);
    font-size: 1.35rem;
    color: #fff;
  }

  .stat-card .stat-value.accent  { color: var(--accent); }
  .stat-card .stat-value.accent2 { color: var(--accent2); }
  .stat-card .stat-value.accent3 { color: var(--accent3); }

  /* Canvas charts */
  .charts-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 20px;
  }

  .chart-box {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 20px;
  }

  .chart-title {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--text-dim);
    letter-spacing: 0.15em;
    margin-bottom: 14px;
  }

  canvas {
    width: 100% !important;
    display: block;
  }

  /* Progress bar */
  .progress-wrap {
    height: 3px;
    background: var(--border);
    border-radius: 2px;
    margin-bottom: 24px;
    overflow: hidden;
    display: none;
  }

  .progress-bar {
    height: 100%;
    background: linear-gradient(90deg, var(--accent), var(--accent3));
    transition: width 0.3s;
    width: 0%;
  }
</style>
</head>
<body>
<div class="container">

  <header>
    <h1>株価 <span>LSTM</span> 予測</h1>
  </header>
  <div class="subtitle">
    <span class="backend-badge" id="backendBadge">BACKEND: —</span>
  </div>

  <!-- Upload -->
  <div class="upload-zone" id="uploadZone">
    <input type="file" id="csvFile">
    <div class="upload-icon">📂</div>
    <div class="upload-label"><strong>CSVファイルをドロップ</strong>またはクリックして選択</div>
    <div class="upload-label" style="margin-top:6px;font-size:0.75rem;">
      形式: "日付","終値","始値","高値","安値"
    </div>
  </div>

  <!-- Config -->
  <div class="config-panel">
    <div class="config-item">
      <label>ウィンドウサイズ(日)</label>
      <input type="number" id="windowSize" value="20" min="5" max="60">
    </div>
    <div class="config-item">
      <label>LSTMユニット数(第1層)</label>
      <input type="number" id="lstmUnits1" value="64" min="16" max="256">
    </div>
    <div class="config-item">
      <label>LSTMユニット数(第2層)</label>
      <input type="number" id="lstmUnits2" value="32" min="8" max="128">
    </div>
    <div class="config-item">
      <label>Dropout率</label>
      <input type="number" id="dropoutRate" value="0.2" min="0" max="0.5" step="0.05">
    </div>
    <div class="config-item">
      <label>エポック数</label>
      <input type="number" id="epochs" value="100" min="10" max="500">
    </div>
    <div class="config-item">
      <label>バッチサイズ</label>
      <input type="number" id="batchSize" value="16" min="8" max="128">
    </div>
    <div class="config-item">
      <label>訓練データ比率 (%)</label>
      <input type="number" id="trainRatio" value="80" min="50" max="90">
    </div>
    <div class="config-item">
      <label>学習率</label>
      <input type="number" id="learningRate" value="0.001" min="0.0001" max="0.01" step="0.0001">
    </div>
  </div>

  <button class="btn" id="trainBtn" disabled>▶ 学習開始</button>

  <!-- Progress -->
  <div class="progress-wrap" id="progressWrap">
    <div class="progress-bar" id="progressBar"></div>
  </div>

  <!-- Log -->
  <div class="log-box" id="logBox">
    <span class="info">[SYSTEM]</span> CSVファイルを読み込んでください...<br>
  </div>

  <!-- Stats -->
  <div class="stats-row" id="statsRow" style="display:none;">
    <div class="stat-card">
      <div class="stat-label">TOTAL RECORDS</div>
      <div class="stat-value accent" id="statRecords">—</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">TRAIN SAMPLES</div>
      <div class="stat-value" id="statTrain">—</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">TEST SAMPLES</div>
      <div class="stat-value" id="statTest">—</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">BEST LOSS</div>
      <div class="stat-value accent3" id="statLoss">—</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">TEST MAE (円)</div>
      <div class="stat-value accent2" id="statMae">—</div>
    </div>
    <div class="stat-card">
      <div class="stat-label">DIRECTION ACC.</div>
      <div class="stat-value accent" id="statAcc">—</div>
    </div>
  </div>

  <!-- Charts -->
  <div class="charts-grid" id="chartsGrid" style="display:none;">
    <div class="chart-box">
      <div class="chart-title">── 学習曲線(Loss / MAE)<span id="lossEpochLabel" style="margin-left:12px;color:var(--accent);"></span></div>
      <canvas id="lossChart" height="200"></canvas>
    </div>
    <div class="chart-box">
      <div class="chart-title">── 予測 vs 実測(テストデータ)</div>
      <canvas id="predChart" height="220"></canvas>
    </div>
    <div class="chart-box">
      <div class="chart-title">── 翌日予測値(直近20日+予測)</div>
      <canvas id="nextChart" height="160"></canvas>
    </div>
  </div>

</div>

<script>
// ─── グローバル状態 
let rawData = [];
let trainMin, trainMax;

// ─── ログ出力 
function log(msg, type='') {
  const box = document.getElementById('logBox');
  const span = document.createElement('span');
  if (type) span.className = type;
  span.textContent = msg;
  box.appendChild(span);
  box.appendChild(document.createElement('br'));
  box.scrollTop = box.scrollHeight;
}

// ─── CSV読み込み 
document.getElementById('csvFile').addEventListener('change', e => {
  const file = e.target.files[0];
  if (!file) return;
  log(`[CSV] ${file.name} を読み込み中...`, 'info');

  Papa.parse(file, {
    skipEmptyLines: true,
    complete: result => {
      const rows = result.data;
      rawData = [];
      let skipped = 0;

      rows.forEach((row, i) => {
        // クォートや空白を除去
        const clean = row.map(c => String(c).replace(/["\s,]/g, '').trim());
        if (clean.length < 5) { skipped++; return; }
        const [date, close, open, high, low] = clean;
        const vals = [close, open, high, low].map(v => parseFloat(v.replace(/,/g, '')));
        if (vals.some(isNaN)) { skipped++; return; }
        rawData.push({ date, close: vals[0], open: vals[1], high: vals[2], low: vals[3] });
      });

      // 日付昇順にソート
      rawData.sort((a, b) => a.date.localeCompare(b.date));

      log(`[CSV] ${rawData.length}件読み込み完了 (スキップ: ${skipped})`, 'ok');
      log(`[CSV] 期間: ${rawData[0].date} 〜 ${rawData[rawData.length-1].date}`, 'info');
      document.getElementById('trainBtn').disabled = false;
    },
    error: err => log(`[ERROR] ${err.message}`, 'warn')
  });
});

// ─── ドラッグ&ドロップ 
const zone = document.getElementById('uploadZone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
  e.preventDefault();
  zone.classList.remove('drag');
  const file = e.dataTransfer.files[0];
  if (file) {
    document.getElementById('csvFile').files = e.dataTransfer.files;
    document.getElementById('csvFile').dispatchEvent(new Event('change'));
  }
});

// ─── 学習ボタン 
document.getElementById('trainBtn').addEventListener('click', runTraining);

async function runTraining() {
  if (rawData.length < 50) { log('[ERROR] データが不足しています', 'warn'); return; }

  const W          = parseInt(document.getElementById('windowSize').value);
  const units1     = parseInt(document.getElementById('lstmUnits1').value);
  const units2     = parseInt(document.getElementById('lstmUnits2').value);
  const dropout    = parseFloat(document.getElementById('dropoutRate').value);
  const epochsNum  = parseInt(document.getElementById('epochs').value);
  const batchSz    = parseInt(document.getElementById('batchSize').value);
  const trainRatio = parseInt(document.getElementById('trainRatio').value) / 100;
  const lr         = parseFloat(document.getElementById('learningRate').value);

  document.getElementById('trainBtn').disabled = true;
  document.getElementById('statsRow').style.display = 'grid';
  document.getElementById('chartsGrid').style.display = 'block';
  document.getElementById('progressWrap').style.display = 'block';

  // ── バックエンド自動選択
  const backend = await selectBackend(batchSz, units1, units2);
  const badge = document.getElementById('backendBadge');
  badge.textContent = 'BACKEND: ' + backend.toUpperCase();
  badge.className = 'backend-badge ' + (backend === 'cpu' ? 'cpu' : 'webgl');
  log(`[BACKEND] ${backend.toUpperCase()} を使用 (batch=${batchSz}, units=${units1}/${units2})`, backend === 'cpu' ? 'warn' : 'ok');

  log('──────────────────────────────────', '');
  log(`[CONFIG] window=${W}, units=${units1}/${units2}, dropout=${dropout}`, 'info');
  log(`[CONFIG] epochs=${epochsNum}, batch=${batchSz}, lr=${lr}`, 'info');

  // ── 特徴量抽出 (close, open, high, low)
  const allFeatures = rawData.map(d => [d.close, d.open, d.high, d.low]);
  const n = allFeatures.length;
  const splitIdx = Math.floor(n * trainRatio);

  // ── スケーリング(訓練データのmin/maxのみ)
  const trainFlat = allFeatures.slice(0, splitIdx).flat();
  trainMin = Math.min(...trainFlat);
  trainMax = Math.max(...trainFlat);
  const scale  = v => (v - trainMin) / (trainMax - trainMin);
  const unscale = v => v * (trainMax - trainMin) + trainMin;

  const scaled = allFeatures.map(row => row.map(scale));

  // ── ウィンドウ分割
  const X = [], Y = [];
  for (let i = W; i < n; i++) {
    X.push(scaled.slice(i - W, i));
    Y.push(scaled[i][0]);
  }

  const splitX = Math.floor((splitIdx - W) * 1);
  const xTrain = X.slice(0, splitX);
  const yTrain = Y.slice(0, splitX);
  const xTest  = X.slice(splitX);
  const yTest  = Y.slice(splitX);

  log(`[DATA] 訓練: ${xTrain.length}件 / テスト: ${xTest.length}件`, 'ok');
  document.getElementById('statRecords').textContent = rawData.length;
  document.getElementById('statTrain').textContent   = xTrain.length;
  document.getElementById('statTest').textContent    = xTest.length;

  // ── TensorFlow.js テンソル
  const xs = tf.tensor3d(xTrain);
  const ys = tf.tensor2d(yTrain, [yTrain.length, 1]);

  // ── モデル構築
  log('[MODEL] LSTM モデルを構築中...', 'info');
  const model = tf.sequential();
  model.add(tf.layers.lstm({ units: units1, inputShape: [W, 4], returnSequences: true }));
  model.add(tf.layers.dropout({ rate: dropout }));
  model.add(tf.layers.lstm({ units: units2, returnSequences: false }));
  model.add(tf.layers.dropout({ rate: dropout }));
  model.add(tf.layers.dense({ units: 1, activation: 'linear' }));
  model.compile({ optimizer: tf.train.adam(lr), loss: 'meanSquaredError', metrics: ['mae'] });
  model.summary();
  log('[MODEL] 構築完了。学習開始...', 'ok');

  // ── 学習履歴
  const lossHistory = [], maeHistory = [];
  let bestLoss = Infinity;

  // ── テスト予測&グラフ更新関数(10エポックごとに呼ぶ)
  // xTest[i] は rawData[splitIdx + i] の翌日 = rawData[splitIdx + i + 1] を予測する
  // → 実測値は rawData.slice(splitIdx + 1) の先頭 xTest.length 件と対応
  const actualClose = rawData.slice(splitIdx + 1, splitIdx + 1 + xTest.length).map(d => d.close);
  const testDates   = rawData.slice(splitIdx + 1, splitIdx + 1 + xTest.length).map(d => d.date);

  async function updateCharts(currentEpoch) {
    document.getElementById('lossEpochLabel').textContent =
      `Epoch ${currentEpoch} / ${epochsNum}`;
    drawLossChart(lossHistory, maeHistory);

    // テスト予測
    const xTestT = tf.tensor3d(xTest);
    const predScaled = model.predict(xTestT).dataSync();
    const predActual = Array.from(predScaled).map(unscale);
    xTestT.dispose();

    // 長さを揃えてから描画(念のため短い方に合わせる)
    const len = Math.min(predActual.length, actualClose.length);
    if (len === 0) return;
    const predTrimmed   = predActual.slice(0, len);
    const actualTrimmed = actualClose.slice(0, len);
    const datesTrimmed  = testDates.slice(0, len);

    drawPredChart(datesTrimmed, actualTrimmed, predTrimmed);

    // 翌日予測
    const lastWindow = scaled.slice(-W);
    const nextInput = tf.tensor3d([lastWindow]);
    const nextScaled = model.predict(nextInput).dataSync()[0];
    const nextPred = unscale(nextScaled);
    nextInput.dispose();
    drawNextChart(rawData.slice(-20).map(d => d.close), rawData.slice(-20).map(d => d.date), nextPred);

    // MAE
    let mae = 0;
    for (let i = 0; i < len; i++) mae += Math.abs(predTrimmed[i] - actualTrimmed[i]);
    mae /= len;
    document.getElementById('statMae').textContent = isNaN(mae) ? '—' : mae.toFixed(0) + '円';

    // 方向的中率
    let correct = 0;
    for (let i = 1; i < len; i++) {
      if ((predTrimmed[i] > predTrimmed[i-1]) === (actualTrimmed[i] > actualTrimmed[i-1])) correct++;
    }
    const acc = len > 1 ? (correct / (len - 1) * 100).toFixed(1) : '—';
    document.getElementById('statAcc').textContent = acc === '—' ? '—' : acc + '%';

    return nextPred;
  }

  await model.fit(xs, ys, {
    epochs: epochsNum,
    batchSize: batchSz,
    shuffle: false,
    callbacks: {
      onEpochEnd: async (epoch, logs) => {
        lossHistory.push(logs.loss);
        maeHistory.push(logs.mae);
        if (logs.loss < bestLoss) bestLoss = logs.loss;

        const pct = ((epoch + 1) / epochsNum * 100).toFixed(0);
        document.getElementById('progressBar').style.width = pct + '%';
        document.getElementById('statLoss').textContent = bestLoss.toFixed(6);

        // 10エポックごと(または最終エポック)にグラフ更新
        if ((epoch + 1) % 10 === 0 || epoch === epochsNum - 1) {
          log(`[EPOCH ${String(epoch+1).padStart(3,'0')}] loss=${logs.loss.toFixed(6)} mae=${logs.mae.toFixed(6)}`, '');
          await updateCharts(epoch + 1);
          // UIを描画させるために1フレーム待機
          await new Promise(r => setTimeout(r, 0));
        }
      }
    }
  });

  log('[TRAIN] 学習完了!', 'ok');

  // 最終予測ログ
  const lastWindow = scaled.slice(-W);
  const nextInput2 = tf.tensor3d([lastWindow]);
  const nextPredFinal = unscale(model.predict(nextInput2).dataSync()[0]);
  nextInput2.dispose();
  const diff = nextPredFinal - rawData[rawData.length - 1].close;
  log(`[NEXT] 翌営業日予測終値: ${nextPredFinal.toFixed(2)}円 (${diff >= 0 ? '+' : ''}${diff.toFixed(2)}円)`, diff >= 0 ? 'ok' : 'warn');

  document.getElementById('trainBtn').disabled = false;
  xs.dispose(); ys.dispose();
}

// ─── 学習曲線グラフ 
function drawLossChart(lossArr, maeArr) {
  const canvas = document.getElementById('lossChart');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * devicePixelRatio;
  canvas.height = 200 * devicePixelRatio;
  ctx.scale(devicePixelRatio, devicePixelRatio);
  const W = canvas.offsetWidth, H = 200;
  const pad = { t: 20, r: 20, b: 40, l: 60 };
  const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;

  ctx.fillStyle = '#0f1118';
  ctx.fillRect(0, 0, W, H);

  const maxLoss = Math.max(...lossArr, ...maeArr);
  const minLoss = Math.min(...lossArr, ...maeArr);
  const n = lossArr.length;

  function toX(i) { return pad.l + (i / (n - 1)) * iw; }
  function toY(v) { return pad.t + (1 - (v - minLoss) / (maxLoss - minLoss)) * ih; }

  // Grid
  ctx.strokeStyle = '#1e2535';
  ctx.lineWidth = 0.5;
  for (let g = 0; g <= 5; g++) {
    const y = pad.t + (g / 5) * ih;
    ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
    const val = maxLoss - (g / 5) * (maxLoss - minLoss);
    ctx.fillStyle = '#4a5a72';
    ctx.font = '10px Share Tech Mono';
    ctx.textAlign = 'right';
    ctx.fillText(val.toFixed(5), pad.l - 6, y + 4);
  }

  // Loss line
  function drawLine(arr, color) {
    ctx.beginPath();
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.5;
    arr.forEach((v, i) => {
      i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v));
    });
    ctx.stroke();
  }
  drawLine(lossArr, '#00d4ff');
  drawLine(maeArr, '#ff6b35');

  // Labels
  ctx.fillStyle = '#00d4ff'; ctx.font = '11px Share Tech Mono'; ctx.textAlign = 'left';
  ctx.fillText('Loss', pad.l + 8, pad.t + 14);
  ctx.fillStyle = '#ff6b35';
  ctx.fillText('MAE', pad.l + 60, pad.t + 14);

  // X axis labels
  ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'center';
  const steps = Math.min(10, n);
  for (let s = 0; s <= steps; s++) {
    const i = Math.round(s / steps * (n - 1));
    ctx.fillText(i + 1, toX(i), H - pad.b + 16);
  }
  ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'center';
  ctx.fillText('Epoch', pad.l + iw / 2, H - 6);
}

// ─── 予測 vs 実測グラフ 
function drawPredChart(dates, actual, pred) {
  const canvas = document.getElementById('predChart');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * devicePixelRatio;
  canvas.height = 220 * devicePixelRatio;
  ctx.scale(devicePixelRatio, devicePixelRatio);
  const W = canvas.offsetWidth, H = 220;
  const pad = { t: 20, r: 20, b: 40, l: 80 };
  const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;
  const n = actual.length;

  ctx.fillStyle = '#0f1118';
  ctx.fillRect(0, 0, W, H);

  const allVals = [...actual, ...pred];
  const minV = Math.min(...allVals) * 0.995;
  const maxV = Math.max(...allVals) * 1.005;

  function toX(i) { return pad.l + (i / (n - 1)) * iw; }
  function toY(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * ih; }

  // Grid
  ctx.strokeStyle = '#1e2535'; ctx.lineWidth = 0.5;
  for (let g = 0; g <= 5; g++) {
    const y = pad.t + (g / 5) * ih;
    ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
    const val = maxV - (g / 5) * (maxV - minV);
    ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'right';
    ctx.fillText(Math.round(val).toLocaleString(), pad.l - 6, y + 4);
  }

  // Actual fill area
  ctx.beginPath();
  actual.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
  ctx.lineTo(toX(n-1), pad.t + ih); ctx.lineTo(toX(0), pad.t + ih); ctx.closePath();
  ctx.fillStyle = 'rgba(0,212,255,0.06)'; ctx.fill();

  // Actual line
  ctx.beginPath(); ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.5;
  actual.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
  ctx.stroke();

  // Pred line
  ctx.beginPath(); ctx.strokeStyle = '#ff6b35'; ctx.lineWidth = 1.5;
  ctx.setLineDash([4, 3]);
  pred.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
  ctx.stroke(); ctx.setLineDash([]);

  // X axis labels
  ctx.fillStyle = '#4a5a72'; ctx.font = '9px Share Tech Mono'; ctx.textAlign = 'center';
  const step = Math.max(1, Math.floor(n / 8));
  for (let i = 0; i < n; i += step) {
    ctx.fillText(dates[i] ? dates[i].slice(2, 10) : '', toX(i), H - pad.b + 14);
  }

  // Legend
  ctx.fillStyle = '#00d4ff'; ctx.font = '11px Share Tech Mono'; ctx.textAlign = 'left';
  ctx.fillText('実測値', pad.l + 8, pad.t + 14);
  ctx.fillStyle = '#ff6b35';
  ctx.fillText('予測値', pad.l + 70, pad.t + 14);
}

// ─── 翌日予測グラフ 
function drawNextChart(recent, recentDates, nextVal) {
  const canvas = document.getElementById('nextChart');
  const ctx = canvas.getContext('2d');
  canvas.width = canvas.offsetWidth * devicePixelRatio;
  canvas.height = 160 * devicePixelRatio;
  ctx.scale(devicePixelRatio, devicePixelRatio);
  const W = canvas.offsetWidth, H = 160;
  const pad = { t: 20, r: 20, b: 36, l: 80 };
  const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;

  const allVals = [...recent, nextVal];
  const n = allVals.length;
  const minV = Math.min(...allVals) * 0.997;
  const maxV = Math.max(...allVals) * 1.003;

  ctx.fillStyle = '#0f1118'; ctx.fillRect(0, 0, W, H);

  function toX(i) { return pad.l + (i / (n - 1)) * iw; }
  function toY(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * ih; }

  ctx.strokeStyle = '#1e2535'; ctx.lineWidth = 0.5;
  for (let g = 0; g <= 4; g++) {
    const y = pad.t + (g / 4) * ih;
    ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iw, y); ctx.stroke();
    const val = maxV - (g / 4) * (maxV - minV);
    ctx.fillStyle = '#4a5a72'; ctx.font = '10px Share Tech Mono'; ctx.textAlign = 'right';
    ctx.fillText(Math.round(val).toLocaleString(), pad.l - 6, y + 4);
  }

  // Divider line before prediction
  const divX = toX(recent.length - 1);
  ctx.beginPath(); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
  ctx.setLineDash([3, 3]);
  ctx.moveTo(divX, pad.t); ctx.lineTo(divX, pad.t + ih); ctx.stroke();
  ctx.setLineDash([]);

  // Recent line
  ctx.beginPath(); ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.8;
  recent.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)));
  ctx.stroke();

  // Connect to prediction
  ctx.beginPath(); ctx.strokeStyle = '#ff6b35'; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]);
  ctx.moveTo(toX(recent.length - 1), toY(recent[recent.length - 1]));
  ctx.lineTo(toX(recent.length), toY(nextVal));
  ctx.stroke(); ctx.setLineDash([]);

  // Prediction dot
  ctx.beginPath();
  ctx.arc(toX(recent.length), toY(nextVal), 5, 0, Math.PI * 2);
  ctx.fillStyle = '#ff6b35'; ctx.fill();
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();

  // Prediction label
  ctx.fillStyle = '#ff6b35'; ctx.font = 'bold 11px Share Tech Mono'; ctx.textAlign = 'left';
  ctx.fillText(Math.round(nextVal).toLocaleString() + '円', toX(recent.length) - 36, toY(nextVal) - 10);

  // X labels
  ctx.fillStyle = '#4a5a72'; ctx.font = '9px Share Tech Mono'; ctx.textAlign = 'center';
  const step = Math.max(1, Math.floor(recent.length / 5));
  for (let i = 0; i < recent.length; i += step) {
    ctx.fillText(recentDates[i] ? recentDates[i].slice(5) : '', toX(i), H - pad.b + 14);
  }
  ctx.fillStyle = '#ff6b35';
  ctx.fillText('予測', toX(recent.length), H - pad.b + 14);
}
</script>
</body>
</html>

『株価 LSTM 予測システム(デモ版)』を公開しました。