ローソク足チャートを1本ずつ表示していく「チャートリプレイ」的な機能を、TradingView製のlightweight-charts v5で実装したときのメモです。v4からv5でAPIがかなり変わっていて、ネット上のv4時代のコード例をそのまま持ってきても動かない箇所がいくつかあったので、実際にハマった点を中心に書きます。
作ったのは、過去のローソク足チャートを先が見えない状態で再生し、その場で仮想売買して成績を確認するチャート練習サイト(kabu-renshu.com)です。本記事では投資判断の話はせず、チャート描画側の実装だけを扱います。
環境
- Next.js 14 (App Router) + TypeScript
-
lightweight-charts:^5.1.0
"lightweight-charts": "^5.1.0"
v5の基本: チャート生成とローソク足シリーズ
v5で一番大きく変わったのは、シリーズの追加方法です。v4では chart.addCandlestickSeries(options) のようにシリーズ種別ごとの専用メソッドが生えていましたが、v5ではそれらが廃止され、chart.addSeries(SeriesDefinition, options) という共通APIに統一されています。
import { createChart, CandlestickSeries } from 'lightweight-charts';
const chart = createChart(container, {
width: container.clientWidth,
height: 400,
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#333',
},
});
// v4: chart.addCandlestickSeries(options)
// v5: addSeries に「何のシリーズか」を渡す
const series = chart.addSeries(CandlestickSeries, {
upColor: '#ef4444',
downColor: '#3b82f6',
wickUpColor: '#ef4444',
wickDownColor: '#3b82f6',
});
series.setData([
{ time: '2024-01-01', open: 100, high: 110, low: 95, close: 105 },
// ...
]);
CandlestickSeries や LineSeries、HistogramSeries は lightweight-charts から名前付きインポートするシリーズ定義オブジェクトで、これを第一引数に渡すことでシリーズ種別を指定します。移動平均線や出来高、VWAPなどの補助指標も同じaddSeries(LineSeries, ...) / addSeries(HistogramSeries, ...) パターンで追加できます。
出来高は価格と同じ軸に重ねると潰れるので、別の価格スケールIDを与えて下に寄せます。
const volume = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.8, bottom: 0 }, // 下2割だけに描画
});
リプレイの作り方: データを隠して1本ずつ流す
チャートリプレイの本質は単純で、「持っているローソク足データのうち、現在の再生位置までしか渡さない」だけです。今回の実装では、再生位置(currentIndex)が進むたびに、その位置までの配列を丸ごとsetDataし直しています。
const updateChartData = (index: number) => {
const visibleBars = bars.slice(0, index + 1).map((b) => ({
time: chartTime(b.timestamp),
open: b.open,
high: b.high,
low: b.low,
close: b.close,
}));
series.setData(visibleBars);
// 移動平均線・出来高・VWAPなども同じ index までの範囲で計算し直して setData
};
series.update() で末尾に1本だけ足していく方式も選べますが、今回は「未表示の指標をON/OFFで切り替える」「巻き戻しボタンで任意のバーまで戻す」という要件があったため、毎回sliceしてsetDataで丸ごと張り替える方式にしました。1本進めるたびに数百本規模の配列を作り直すことになりますが、体感できる遅延は出ていません。巻き戻しも「小さいindexで同じ関数を呼ぶだけ」で実現できるのがこの方式の利点です。
再生の1コマは、currentIndexをインクリメントしてupdateChartDataを呼ぶだけの単純なタイマー処理です(速度は1倍・2倍・5倍・10倍を切り替え可能にしています)。
未来のデータを一切見せないためには、テクニカル指標側も「現在のバーまでの区間」だけで計算する必要があります。単純移動平均であれば次のように、終端インデックス(endIndex)を受け取って範囲内だけを集計する形にしておくと、リプレイの現在位置をそのまま渡せます。
export function calcSMA(data: ScenarioBar[], period: number, endIndex: number) {
const result = [];
for (let i = period - 1; i <= endIndex; i++) {
let sum = 0;
for (let j = i - period + 1; j <= i; j++) sum += data[j].close;
result.push({ time: chartTime(data[i].timestamp), value: sum / period });
}
return result;
}
RSIやATRのようなWilder平滑を使う指標も同様に、endIndexより先のバーには一切触れない実装にしています。これを徹底しないと、「本来は見えないはずの未来の値動きで指標が先回りしてしまう」というリプレイ機能として致命的なバグになります。
売買の結果はマーカーとして描画します。v4ではseries.setMarkers()というメソッドがシリーズに直接生えていましたが、v5ではそのメソッド自体が廃止されており、代わりにcreateSeriesMarkers(series, initialMarkers)でマーカー専用のプラグインオブジェクトを作り、そのオブジェクトのsetMarkers()を呼ぶ形に変わっています。
import { createSeriesMarkers } from 'lightweight-charts';
const markersPlugin = createSeriesMarkers(series, []);
// 売買が発生するたびに呼び直す
markersPlugin.setMarkers([
{ time: chartTime(entryBar.timestamp), position: 'belowBar', shape: 'arrowUp', color: '#ef4444', text: '買' },
{ time: chartTime(exitBar.timestamp), position: 'aboveBar', shape: 'arrowDown', color: '#16a34a', text: '決済 +120' },
]);
マーカーは時刻の昇順であることが要求されるため、複数の売買イベントを混ぜて配列を作るときはsort((a, b) => a.time - b.time)を忘れずに入れています。
ハマったポイント
1. addCandlestickSeriesがそのまま無い
v4時代の記事でよく見るchart.addCandlestickSeries(options)はv5には存在しません。chart.addSeries(CandlestickSeries, options)という第一引数にシリーズ定義を渡す形に変わっており、LineSeries・HistogramSeriesも同様です。この呼び出し規約を知らずにv4のコード例をコピペすると、その場でエラーになって気づきます。
2. 時刻は「常にUTC」として描かれる
timeに数値(UNIX秒)を渡すと、lightweight-chartsはタイムゾーンの概念を持たず常にUTCとして解釈して描画します。日本時間の09:00をそのまま渡すと、チャート上には00:00と表示されてしまい9時間ずれます。今回はJSTの壁時計時刻をそのままUTCとして解釈させたい(=表示だけ合わせたい)ので、表示用の時刻にだけ9時間分(9 * 60 * 60)を足しています。売買記録側は元のISO文字列を別に保持しているので、この補正の影響は受けません。
const JST_OFFSET_SEC = 9 * 60 * 60;
export const chartTime = (timestamp: string) =>
Math.floor(new Date(timestamp).getTime() / 1000) + JST_OFFSET_SEC;
3. 帯(一目均衡表の雲)を塗る標準機能が無い
一目均衡表の先行スパン1・2の間を塗りつぶす「雲」は、線シリーズを2本重ねるだけでは作れません。v5にはシリーズプリミティブという仕組みがあり、series.attachPrimitive(primitive)でチャートの描画にカスタム処理を差し込めます。今回はこれを使い、paneViews()が返すrenderer().draw(target)の中で、2本のスパンの座標をtimeScale.timeToCoordinate()とseries.priceToCoordinate()で求めてCanvasに直接パスを描いています。強気/弱気が入れ替わる区間は線の交点で分割してから塗らないと、色の境界がねじれて実態と逆になる箇所ができるので注意が必要でした。
4. コンテナの高さをどう決めるか
チャートはcreateChart時点のheightを固定値で渡す必要があり、レイアウトの都合で高さを可変にしたい場合は自前で計算してchart.applyOptions({ height })を呼び直す必要があります。今回はサブ指標パネル(RSI・MACD・ATR)の表示数によって使える高さが変わる作りにしたため、「画面の高さ - チャート以外の要素の実測合計」で毎回再計算しています。document.documentElement.scrollHeightから引く方式だと、ページがビューポート内に収まっている場合はscrollHeightが画面高で頭打ちになり、要素を減らしても高さが伸びないという事象にはまりました。最終的には、チャートを含む親要素のgetBoundingClientRect().heightから実際のチャート高さを引いた値を基準にする方式に落ち着いています。
まとめ
lightweight-charts v5でのチャートリプレイ実装は、大枠では「表示するデータ配列を再生位置で区切って毎回setDataし直すだけ」というシンプルな仕組みです。ただしv4からのAPI変更(addSeriesへの統一、createSeriesMarkersによるマーカー管理)を把握していないと、v4時代の情報を頼りにした実装では動かない箇所につまずきます。また、時刻をUTC固定で扱う仕様や、帯を塗る標準機能が無い点は、v5特有というよりlightweight-charts全般の設計思想として押さえておくと良さそうです。