AIツクリラボ
・Next.js制作Tips・19分で読めます・🔵 実装記録

自宅PCで動くAI自動売買の結果を、Next.jsの静的サイトで公開する運用ダッシュボードの作り方

この記事で分かること
自宅PCのDBに直接つながれないVercel上のサイトに、JSONファイルをgitで運ぶ構成で実データのダッシュボードを載せる方法と、チャートライブラリを使わないSVG折れ線グラフの実装
実際に使った環境
Next.js 16.3.1(App Router、SSG+ISR)/ Vercel / Python(trading/サブプロジェクト)。このブログの運用状況ページ(/results)の実装そのものです
筆者の結論
データの運び方はgit pushで十分だった。手間がかかったのはチャートの細部(元本の起点・比較線の見分けやすさ・ツールチップの位置)と、実験初期の少ない数字を大きく見せない表示の設計

このブログでは、AIに30万円を渡して日本株を自動売買させる実験の結果を、運用状況ページで公開しています。総資産、元本からの損益、保有銘柄、AIの売買履歴と判断理由、日経平均・TOPIXとの比較グラフまで、実際の証券口座のデータをそのまま載せているページです。

ただ、このブログはNext.jsで作った静的サイトで、Vercelでホスティングしています。一方、自動売買のシステムは自宅のWindows PCで動くPythonのプログラムで、データもそのPCの中にあります。この記事では、この2つをどうつないでいるのか、そしてダッシュボードの表示で工夫した点を、実装に沿って紹介します。

🛠 実装してみて分かったこと

最初は「リアルタイムのダッシュボード」と聞いて、APIやデータベースを用意する大がかりな構成を想像していました。実際には、毎日1回JSONファイルを書き出してgit pushするだけで足りました。むしろ時間を使ったのは表示のほうで、特に「勝率100%」のような、運用初期の少ない母数だと簡単に出てしまう数字を、どう誇張せずに見せるかでした。

全体の構成: JSONをgitで運ぶ

データの流れは次のとおりです。

  1. 自宅PCのPythonが、毎営業日の引け後に、DBから集計したデータを3つのJSONファイルに書き出す
  2. 同じ処理の中で、その日のトレード日誌の記事と一緒にgit commit・git pushする
  3. pushをきっかけにVercelが再ビルドし、Next.jsがJSONを読み込んでページを生成する
ファイル中身
content/data/trading-status.json現在の総資産・現金・保有銘柄・勝率などのスナップショット
content/data/trading-calendar.json営業日ごとの総資産・その日の損益・日経平均/TOPIXの比較値
content/data/trading-history.jsonAIが実際に発注した売買と、その判断理由

VercelからDBへ直接つながない理由は単純で、DBが自宅PCの中にあるからです。直接読ませるには、自宅側に外部から接続できる口を開けるか、DBをクラウドに移す必要があります。どちらも、1日1回更新されれば十分なページのためには大げさでした。

JSONをgitで運ぶ方式なら、ブログ記事(Markdown)を公開するのとまったく同じ流れに乗せられます。公開サイトが自宅PCに一切アクセスしないので、自宅PCの電源が落ちていても、サイトは最後に届いたデータで表示され続けます。

Next.js側の読み込みは、ファイルを読んでJSONとして解釈するだけです。

const filePath = path.join(process.cwd(), "content/data/trading-status.json");
 
export function getTradingStatus(): TradingStatus | null {
  if (!fs.existsSync(filePath)) return null;
  const raw = fs.readFileSync(filePath, "utf8");
  return JSON.parse(raw) as TradingStatus;
}

ファイルが無い場合はnullを返し、ページ側で「まだ運用データがありません」と表示します。JSONの形はTypeScriptの型(src/types/trading-status.ts)として定義しており、Python側でフィールドを追加したときは、型にもコメント付きで追記しています。

ページ自体はrevalidate = 3600の設定で、記事ページと同じく1時間ごとに再生成されます。

ページの構成

運用状況ページは、上から順に次の要素を並べています。

  • ファーストビュー: 「AIに30万円預けて、日本株を自動売買させてみる」という見出しと、いつから・何をしているかの説明、投資助言ではない旨の注記
  • 主要な数字: 総資産、元本からの損益、目標(100万円)までの残り
  • 補足情報: 運用開始日、初期元本、含み損益、保有銘柄数、発注件数(買い+売り)
  • 総資産の推移グラフ: 日経平均・TOPIXとの比較線つき
  • 保有銘柄: 銘柄名をクリックするとGoogleファイナンスの該当ページを新しいタブで開く
  • AIトレード履歴: 直近5件の売買だけを、AIの判断理由(折りたたみ)つきで表示。それより前の分は日誌一覧へのリンクから読む
  • 運用開始からの実績: 勝率・平均利益・平均損失・最大ドローダウン
  • 月間・累計の実現損益と日次カレンダー: 日付を選ぶとその日のトレード日誌に移動

運用開始日は、コードに直接書かず、カレンダーの最初の記録日(=最初のトレード日誌の日付)から取っています。日付を別に書いておくと、後から見返したときに実データとずれる恐れがあるためです。

総資産の推移グラフをSVGで描く

このサイトにはチャート用のライブラリが入っていません。必要なのは折れ線3本とツールチップ程度だったので、SVGを直接組み立てることにしました。ホバー時の状態を持つため、このグラフ(AssetHistoryChart)だけはクライアントコンポーネントです。

座標の計算

値の範囲(最小値〜最大値)を求め、上下に10%の余白を足してから、SVGの座標に変換します。

const domainMin = Math.min(initialCapital, ...values, ...benchmarkValues, ...topixBenchmarkValues);
const domainMax = Math.max(initialCapital, ...values, ...benchmarkValues, ...topixBenchmarkValues);
const domainSpan = Math.max(domainMax - domainMin, 1);
const paddedMin = domainMin - domainSpan * 0.1;
const paddedMax = domainMax + domainSpan * 0.1;
const paddedSpan = paddedMax - paddedMin;
 
const xFor = (index: number) =>
  chartDays.length === 1
    ? PADDING.left + PLOT_WIDTH / 2
    : PADDING.left + (PLOT_WIDTH * index) / (chartDays.length - 1);
const yFor = (value: number) => PADDING.top + PLOT_HEIGHT * (1 - (value - paddedMin) / paddedSpan);

範囲の計算には、AIの総資産だけでなく、日経平均・TOPIXの比較値も含めています。比較線のほうが大きく動いたときに、線がグラフの外にはみ出さないようにするためです。Math.max(..., 1)は、データが1点しかない、あるいは値がまったく動いていないときに、0で割ってしまうのを防ぐための最低幅です。

左端を必ず「元本」にする

最初の実装では、グラフの左端が最初の営業日の総資産から始まっていました。しかしこのグラフで見せたいのは「元本からどれだけ動いたか」です。そこで、表示用のデータの先頭に、元本の点を合成して足しています。

const chartDays: ChartDay[] = [
  {
    date: "principal",
    totalAsset: initialCapital,
    dailyPnl: 0,
    slug: "",
    benchmarkAsset: initialCapital,
    benchmarkTopixAsset: initialCapital,
    isPrincipal: true,
  },
  ...visibleDays,
];

元本の点は、JSONのデータそのものには入れていません。カレンダーの各日は必ず実際のトレード日誌に対応しているので、架空の「元本の日」を混ぜると、同じデータを使う日次カレンダー側が壊れるためです。グラフの表示用にだけ合成しています。期間を「30日」などに絞り込んでも、先頭には常に元本の点を置きます。

日経平均・TOPIXとの比較線

AIの成績を評価するには、「何もしないで市場全体に投資していたらどうだったか」との比較が欠かせません。そこで、入金した日に元本と同じ額を日経平均(TOPIX)に投資していたら、その日にいくらになっているかを計算し、比較線として重ねています。計算はPython側で行っていて、式は「元本 × その日の指数 ÷ 入金日の指数」です。

線の見分けやすさには何度か手を入れました。

  • 日経平均の線が薄すぎた: サイトのパレットにあるペールブルーをそのまま使うと、背景とのコントラストが足りませんでした。CSSのcolor-mix()で文字色と混ぜ、濃さを上げています。ライト/ダークどちらのテーマでも、CSS変数の値に応じて自動で見やすい濃さになります
  • TOPIXの線が文字色と区別できなかった: 最初はネイビー系で作りましたが、このサイトではネイビーが本文の文字色と同じ値でした。色相をダスティローズに変え、さらに破線のパターンも変えています(日経平均は5 4の破線、TOPIXは短い点線)。色の区別がつきにくい場合でも、線の形で見分けられます
const benchmarkColor = "color-mix(in srgb, var(--accent-blue) 55%, var(--foreground) 45%)";
const topixBenchmarkColor = "color-mix(in srgb, var(--accent-mauve) 55%, var(--foreground) 45%)";

ホバーの当たり判定を「点」から「帯」に広げる

最初は、データ点の上にマウスを乗せたときだけツールチップが出る作りでした。しかし点は小さく、狙って乗せるのは面倒です。そこで、隣の点との中間までを1つの縦長の帯とみなし、帯のどこにマウスが入ってもその日のツールチップを出すようにしました。

const hoverBands = points.map((p, i) => {
  const left = i === 0 ? PADDING.left : (points[i - 1].x + p.x) / 2;
  const right = i === points.length - 1 ? CHART_WIDTH - PADDING.right : (p.x + points[i + 1].x) / 2;
  return { index: i, left, width: Math.max(right - left, 0) };
});

帯は透明な<rect>として、点より手前に描きます。キーボードで操作する場合は、点に重ねた透明な円にtabIndex={0}とaria-labelを付けてあり、Tabキーで1日ずつ移動できます。

なお、このグラフでは一度Hydrationエラーが出ています。データ点に入れていたSVGの<title>が原因で、その経緯はHydrationエラーの原因がSVGのtitle要素だった話にまとめました。

右端でツールチップが潰れる問題

ツールチップは、点の真上に中央寄せ(translateX(-50%))で表示していました。ところが、グラフの右端に近い点でツールチップを出すと、中身が細い列に押し込まれて何行にも折り返されていました。

絶対配置の要素は、leftの位置から親要素の右端までの残り幅に収まるように幅が決まります。右端の近くでは残り幅がほとんど無いため、ツールチップ自体が狭くなっていたのです。点の位置に応じて、寄せ方を切り替えることで直しました。

transform: `translate(${
  hovered.x / CHART_WIDTH < 0.15 ? "0%" : hovered.x / CHART_WIDTH > 0.85 ? "-100%" : "-50%"
}, -100%)`,

左端15%では左寄せ、右端15%では右寄せ、それ以外は中央寄せです。

少ない母数を誇張しない表示

実験を始めて間もない時期は、トレード数が少ないため、数字が極端になりがちです。数回勝っただけで「勝率100%」と表示されてしまいます。これをそのまま大きく出すと、実力以上に良く見せていると受け取られかねません。そこで、表示にいくつかのルールを設けました。

  • 勝率には必ず母数を添える: 「100%(3戦)」のように、トレード数を同じカードの中に表示します。見出しの下には「運用開始直後で母数が少ないため、長期的な優位性を示すものではありません」と明記しています
  • まだ計算できない値は「-」にする: 売却が1件も無い間は、勝率を0%ではなく「-」と表示します。0%と表示すると「全敗」と誤解されるためです。JSONでもこの状態をnullで表し、型で区別しています
  • AIの判断理由は、記録をそのまま出す: トレード履歴の判断理由は、システムが発注時に記録したAIの出力そのものです。サイト側で要約したり、それらしい理由を生成したりはしていません。長文になるため<details>で折りたたみ、表示も直近5件までに絞っています(6件目以降はHTMLにも出力しません)。見出しの下には「AIの出力を無修正で掲載しているため、数値や用語の誤りを含むことがある」と明記しています
  • 数字ごとに、何を数えた値かを書く: 「元本からの損益」は含み損益込み・税引前、「累計実現損益」は売却で確定した分だけの税引後、のように、似た名前の数字でも集計範囲が違います。ラベルに括弧書きで範囲を添え、カードの下に計算方法を短く書いています
  • 本番運用前は、そのことを明示する: isLiveTradingがfalseの間は、「ペーパートレード検証中」の注記を必ず表示する作りにしています。実際には発注していない結果を、本番運用の成果のように見せないためです
<StatCard
  label="勝率(母数つき)"
  value={stats.winRate === null ? "-" : `${(stats.winRate * 100).toFixed(0)}%(${stats.totalTrades}戦)`}
/>

よくある質問

Q. データベースをVercelから直接読めばよいのではないですか? A. 自動売買のシステムは自宅のWindows PC上で動いていて、DBもそのPCの中にあります。公開サイトのVercelから自宅PCのDBへ直接つなぐには、自宅側に外部から接続できる口を開ける必要があり、避けました。代わりに、必要な集計結果だけをJSONに書き出して、ブログ記事と同じgit pushの流れでサイトに届けています。

Q. なぜチャートライブラリを使わなかったのですか? A. このサイトはもともとチャート用のライブラリを持っておらず、必要なのは折れ線3本と簡単なツールチップだけだったためです。SVGを直接書けば、依存パッケージを増やさずに、配色もサイトのCSS変数とそのまま揃えられます。

Q. ページはどのくらいの頻度で更新されますか? A. Python側が毎営業日の引け後にJSONを書き出してgit pushし、Vercelの再ビルドで反映されます。ページ自体も1時間ごとの再生成(revalidate = 3600)を設定しています。

まとめ

自宅PCで動くシステムの結果を静的サイトで公開するのに、専用のAPIやデータベースは必要ありませんでした。集計結果をJSONに書き出し、ブログ記事と同じようにgit pushするだけで、Vercel側は普段どおりビルドして表示します。

その分、時間をかけたのは表示の側です。元本を起点にしたグラフ、市場全体との比較線、そして少ない母数の数字を誇張しない見せ方。実験の結果を「そのまま」公開するには、数字を正しく持ってくることと同じくらい、数字の見せ方に気を配る必要がありました。

この記事を書いた人

ラボ管理人

AIツール・ノーコード・Next.jsで実際に手を動かしながら、AIツクリラボを運営しています。

運営者情報を見る →