株価分析ツール「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>
米株: <span style="color:var(--accent)">AAPL</span>
BTC: <span style="color:var(--accent)">BTC-USD</span><br>
日経225: <span style="color:var(--accent)">^N225</span>
S&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.T → 7203.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"> </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>
米株: <span style="color:var(--accent)">AAPL</span>
BTC: <span style="color:var(--accent)">BTC-USD</span><br>
日経225: <span style="color:var(--accent)">^N225</span>
S&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"> </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} 値: ${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} ヒスト: ${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} 上: ${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>・ダウンロードされる方はこちら。↓
ここから先は
¥ 300
この記事が気に入ったらチップで応援してみませんか?


購入者のコメント