csv ファイルを読み込んでチャート表示
カンマ区切りの csv ファイルを利用してチャートを表示してみました。
SBI 証券の HyperSbi2 では 20 年程度の時系列データをエクスポートできますのでこれを利用します。
そもそも HyperSbi2 でそのままチャートを表示すればいいだけですが、ここではデータの整形手順を説明するためにこのデータを利用します。
csv の構造
HyperSbi2 の csv データの特徴として、3 桁までの数値はそのままですが 4 桁以上の数値はダブルクォーテーションで囲まれたカンマを含む 3 桁区切りで格納されているのが特徴で、単純に.split(',')
や.split('","')
等では正確な価格を取得することができません。
そこで、各行の模試列を先頭から区切り文字ごとに分割して、残りの先頭文字がダブルクォーテーションかどうかで次の区切り文字を「,」ないし「"」として文字列を取得することにしました。
また、ここでは利用しませんが、MA 等のデータが存在しない列は「--」が記録されています。
本当は csv ファイルを同期的に読み込みたかったのですが、await 等は C#のものとは比べ物にならないくらい難しく12、気軽に入門はできないとあきらめて、以下のページを参考にして3、onload イベント内からチャート描画の function を呼ぶ方法にしました。
加えて、Mac と Windows でエクスポートしたファイルのエンコードが異なるようなので、encoding-japanese4を利用して自動判別できるようにしてみました5。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title></title>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.0.0"></script>
<script src="https://www.chartjs.org/chartjs-chart-financial/chartjs-chart-financial.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/encoding-japanese/2.0.0/encoding.min.js"></script>
<style>
.info{font-size: small;}
.json{width: 460px; height: 320px;}
</style>
</head>
<body>
<div><input type="file" id="selectfile"><label class="info" id="info"></label></div>
<div> <canvas id="chart"></canvas> </div>
<!-- json出力エリア 手書き修正でチャートに反映することは未対応 -->
<div><textarea class="json" id="json"></textarea></div>
<script src="chart-viewer1.js"></script>
<script src="chart-csv-reader.js"></script>
</body></html>
document.getElementById("selectfile").addEventListener("change", function (event) {
const file = event.target.files[0];
const reader = new FileReader();
// reader.readAsText(file);
reader.readAsArrayBuffer(file);
reader.onload = () => {
// 8ビット符号なし整数値配列と見なす
const array = new Uint8Array(reader.result);
const strEncoded = autoEncoding(array);
const prices = csvToArray(strEncoded);
if (prices.error === undefined) {
changeBrand(prices);
document.getElementById("json").value = JSON.stringify(prices).replace( /},/g, "},\n" );
} else {
document.getElementById("json").value = `${prices.error} の列番号が認識できません\n中断しました`;
}
};
});
// refer to https://setchi.hatenablog.com/entry/2014/07/25/231318
function autoEncoding(array) {
// 文字コードを取得
switch (Encoding.detect(array)) {
case "UTF16":
// 16ビット符号なし整数値配列と見なす
array = new Uint16Array(e.target.result);
break;
case "UTF32":
// 32ビット符号なし整数値配列と見なす
array = new Uint32Array(e.target.result);
break;
}
// Unicodeの数値配列に変換
// Unicodeの数値配列を文字列に変換
return Encoding.codeToString(Encoding.convert(array, "UNICODE"));
}
function csvToArray(content) {
const lines = content.split("\n");
// 1行目はフィールドであること 1行目から列番号取得
const fields = lines[0].split(",");
const fieldIndexes = [6];
const label_eng = ["date", "open", "high", "low", "close", "vol"];
const label_jp = ["日付", "始値", "高値", "安値", "終値", "出来高"];
for (let j = 0; j < label_eng.length; j++) {
for (let i = 0; i < fields.length; i++) {
if ( fields[i].toLowerCase().indexOf(label_eng[j]) > -1 || fields[i].toLowerCase().indexOf(label_jp[j]) > -1 ) {
fieldIndexes[j] = i;
break;
}
}
if (j < 5 && fieldIndexes[j] === undefined) {
return { error: label_jp[j] };
}
}
// 汎用性を高めるためcsvフィールド名を解析する仕様にしたら複雑になりすぎ 勘違いがあるかも
const prices = [];
for (let i = 1; i < lines.length; i++) {
if (lines[i] === "") continue;
const cols = [];
let line = lines[i];
// 3桁は数値、4桁以上は3桁区切り ""で囲まれている
for (let j = 0; j < fields.length; j++) {
const delimiter = line.substr(0, 1) == '"' ? '"' : ",";
if (delimiter === '"') line = line.substr(1);
if (fieldIndexes.includes(j)) {
cols[fieldIndexes.indexOf(j)] = line.substr(0, line.indexOf(delimiter)).replace(/,/g, "");
}
line = line.substr(line.indexOf(delimiter) + (delimiter === '"' ? 2 : 1));
}
// luxonは「/」区切りの日付は認識出来ないようだ
// adjust priceがある場合には未対応でもう一工夫が必要(各priceに * adjust / close)
const date = cols[0].replace(/\//g, "-").replace("年", "-").replace("月", "-").replace("日", "");
const data = {
d: date, // console.log等で確認用に
x: luxon.DateTime.fromSQL(date).valueOf(),
o: Number(cols[1]),
h: Number(cols[2]),
l: Number(cols[3]),
c: Number(cols[4]),
};
if (cols[5] !== undefined && !isNaN(cols[5])) {
data.v = Number(cols[5]);
}
prices.push(data);
}
// csvは逆順のため日付で昇順に constもsortできるようだ
return prices.sort((a, b) => a.x - b.x);
}
時系列データは逆順のままでもチャートは昇順に表示されるようですが、マウスポインタの移動で表示されるツールチップの挙動がおかしくなるようなので、昇順にソートをしています。
取得した時系列データ配列を chart-viewer1.jsの changeBrand に渡すと同時にTextAreaにJsonデータとして出力します。
const colors = {
candle: { up: "orangered", down: "limegreen", unchanged: "dimgray" },
volume: "blue",
};
const ctx = document.getElementById("chart").getContext("2d");
const chart = new Chart(ctx, {
type: "candlestick",
data: { datasets: [] },
});
// チャートに表示するデータを格納したグローバルデータ変数
let chartData = {};
// 銘柄(ファイル)変更
function changeBrand(dailyPrices) {
chartData = {
candle: dailyPrices,
volume: dailyPrices.find(d => d.v) ? dailyPrices.map(d => { return { x: d.x, y: d.v } }) : undefined,
};
document.getElementById("info").textContent =
`${dailyPrices[0].d} ${dailyPrices[dailyPrices.length - 1].d} ~ ${dailyPrices.length.toLocaleString()}本`;
changeDatasource();
}
function changeDatasource(barCount = 0) {
if (chartData.candle === undefined) return;
// barCount 0 の場合は全て表示する データ期間のフィルター用
if (barCount === 0)
barCount = chartData.candle.length;
chartData.from = {
index: chartData.candle.length - barCount,
d: chartData.candle[chartData.candle.length - barCount].d,
x: chartData.candle[chartData.candle.length - barCount].x,
};
// chartDataからchartのデータセットを設定していく
// ここで各データに関して表示本数でフィルターをかける
const datasets = [];
const data_candles = {
data: chartData.candle.filter(d => d.x >= chartData.from.x),
color: colors.candle,
yAxisID: "yAxisMain",
};
datasets.push(data_candles);
if (chartData.volume !== undefined) {
const ds_volume = {
type: "bar",
label: "volume",
data: chartData.volume.filter(d => d.x >= chartData.from.x),
borderColor: colors.volume,
backgroundColor: colors.volume,
borderWidth: 1,
pointRadius: 0,
yAxisID: "yAxisSub",
};
// 出来高データを追加
datasets.push(ds_volume);
}
chartData.datasets = datasets;
refreshChart(chartData);
}
// チャートにデータセットの設定とオプション設定
function refreshChart(chartData) {
chart.config.data.datasets = chartData.datasets;
chart.config.options = {
plugins: {
title: { display: false },
legend: {
position: "top",
align: "center",
labels: {
filter: function (item) {
// console.log(item);
//ローソク足とvolumeの凡例を非表示
if (item.datasetIndex === 0 || item.text === "volume") return false;
else return true;
},
boxWidth: 6,
boxHeight: 2,
},
},
},
//再度アクセスする場合に初回定義しなければ undefined となる
scales: {},
};
if ( chart.config.data.datasets.length > 1 && chart.config.data.datasets[1].label == "volume") {
//データセットから出来高max取得
const volumes = chart.config.data.datasets[1].data.map(d => { return d.y });
const volume_max = volumes.reduce(function (a, b) { return Math.max(a, b) });
chart.config.options.scales.yAxisSub = {
suggestedMax: volume_max * 3,
position: "right",
ticks: {
callback: function (value, index) {
if (value > volume_max || value < (volume_max * 2) / 4 || value == 0)
return "";
else
return `${(value / 10000).toLocaleString()}万`;
},
mirror: true,
},
grid: {
drawOnChartArea: false,
},
};
}
chart.update();
}
もっとシンプルな方法もあると思います。