📈

日本株の情報収集サービス「株ビジョン」を個人開発する上で工夫したこと

2025/02/14に公開

「株ビジョン」ってどんなサービス?

株のトレードをする上で、情報収集は非常に重要です。
しかし、各ウェブサイトに散らばった情報を集めるのは非常に手間がかかります。
そこで、株の情報収集を効率化するためのサービス「株ビジョン」を開発し、2024 年 5 月にリリースしました。

リリースしたサービスの URL はこちら。

https://stockscope.app?utm_medium=referral&utm_campaign=zenn&utm_id=8ff6850ef5e0b3

リリースしてからかなり時間が経ってしまいましたが、今回は開発時に使用した技術や、背景、工夫した点などを紹介したいと思います。

ちなみに、「株ビジョン」は、私にとって 2 個目の個人開発サービスです。
1 個目のサービス開発での反省点を活かし、今回は収益を見込んでからの開発を意識して開発を進めました。
1 個目のサービスは、Zenn の記事で紹介していますので、興味がある方はこちらもご覧ください(サービス自体は放置状態です)

https://zenn.dev/h_sakano/articles/eaec32b780685e

株ビジョンでできること

日々更新される需給情報の確認

株は需給のバランスが重要です。
例えば、信用買い残が増え、信用買い返済の売り需要が増えている銘柄は、株価上昇しづらいこと知られています。
よって、信用買い残の推移を確認することで、トレードの判断材料の 1 つにできます。
株ビジョンでは、日々更新される信用残高の情報などを収集し、グラフィカルに表示できます。

また、ウォッチリストに自分の監視銘柄を登録することで、自分の監視銘柄の情報をまとめて確認できます。

板状況や出来高などのリアルタイム監視(デイトレード用)

楽天 RSS というサービスを利用して、リアルタイムの板状況や出来高などの情報を収集し、リアルタイムで表示できます。


Geminiを使った適時開示要約

適時開示情報は、株価に大きな影響を与えることがあります。
株ビジョンでは、Gemini(Vertex AI Platform)を使用し、適時開示情報を要約して表示できます。


開発背景

私は 2023 年末頃に、株のトレードを始めたのですが、株に関する情報は、各ウェブサイトに散らばっているため、情報収集に非常に時間がかかることに気づきました。
情報収集に時間がかかることでトレードの判断が遅れ、機会損失をしてしまうことがあり、同様の経験は他のトレーダーにもあるのではないかと考え、情報収集を効率化するためのサービスを開発することにしました。

リリース当初は「日々更新される需給情報」をグラフ表示するだけのサービスでした。
しかし、ありがたいことにたくさんのトレーダーに利用していただき、フィードバックをいただいたことで、機能を追加していった結果、現在のサービスに成長しました。

また、私は株トレードの勉強をするため、Trader TOMO 株オンラインサロンという株サロンに所属しており、サロンメンバーの方にたくさんのご協力をいただきました。
サロンの一般メンバー以上には、株ビジョンのスタンダードプラン利用料が永久半額となるクーポンを配布しておりますので、ご興味のある方はぜひご利用ください。

https://mediable.jp/channels/tradertomo

技術構成

カテゴリ 選定技術
フレームワーク Next.js(App Router)
API tRPC + Zod
インフラ Firebase, Supabase, Google Cloud Platformの各サービス
インフラ構成 Terraform
UI ライブラリ Ant Design
Lint ESLint
テスト Jest
スクレイピング Playwright(Python, Node.js)
CI/CD GitHub Actions, Cloud Build
適時開示要約 Gemini(Vertex AI Platform)

フレームワーク

フロントエンドは Next.js(App Router) を採用しました。
今回のサービスでは、Google 検索からのアクセスも想定していたため、様々な Rendering 方式を場合によって使い分けることができる、Next.js が適していると考えました。
他にも選択肢があったかもしれませんが、開発速度を優先したいということもあり、使用経験のある Next.js を採用しました。
また、開発を始めたときは、App Router がリリースしたてだったこともあり、単純に使ってみたいという気持ちもありました。

API

tRPC は TypeScript で書かれた API フレームワークで、型安全な API を簡単に作成できるので、採用しました。
Zod とも相性がよく、型安全な API を簡単に作成でき、開発体験が向上しました。

インフラ

インフラは Firebase, Supabase, Google Cloud Platform の各サービスを採用しました。

  • Firebase: ユーザー認証、データベース、ホスティング、ストレージ
  • Supabase: データベース
  • Google Cloud Platform: Web サーバー(Cloud Run), 情報収集(Cloud Run 関数, Cloud Run ジョブ, Cloud Scheduler 等)

運用費用をできるだけ抑えたいということで、基本的には Firebase, Supabase を使用し、必要に応じて Google Cloud Platform を使用するようにしました。
データベース以外は常時起動しているサーバーはなく、必要に応じてサーバーを起動するような構成とすることで、運用費用を抑えることができました。
デイトレ用のサービスについては、保存するデータ量が膨大のため、BigQuery を使用し、データの保存、分析をしました。

UIライブラリ

UI ライブラリは Ant Design を採用しました。
Next.js の App Router に対応していて、デザインも個人的に好みなので採用しました。

Lint

Lint は ESLint を採用しました。
ESLint は Next.js でのデフォルトの Lint としても採用されているため、採用しました。

テスト

テストは Jest を採用しました。
Next.js でのデフォルトのテストフレームワークとしても採用されているため、採用しました。
今回、 E2E テストの実装は、コストバランスを考えて行っていません。
今後サービスが成長していく中で、 E2E テストの実装を検討していきたいと考えています。

スクレイピング

情報収集をするためのスクレイピングは Playwright を採用しました。
Playwright は Puppeteer に比べて、ブラウザの操作が簡単に行えるということで採用しました。
また、Playwright は Python, Node.js どちらでも使用できるため、開発者の選択肢が広がるというメリットもあります。

CI/CD

CI/CD は GitHub Actions, Cloud Build を採用しました。
GitHub Actions は GitHub との連携が簡単で、無料で使えるため採用しました。
Cloud Build は Google Cloud Platform との連携が簡単で、Cloud Run へのイメージのデプロイが簡単に行えるため採用しました。

工夫した点

それぞれの項目で記事が 1 本書けるほどなのですが、ここでは簡単に紹介します。

収益を見込んでからの開発

「開発背景」でも述べた通り、株ビジョンは株のオンラインサロンのメンバーからのフィードバックをたくさんいただける、ありがたい環境で開発できました。
まず、プロトタイプを Google Spreadsheet で作成し、サロンメンバーにフィードバックをいただき、需要があることを確認してから開発に踏み切りました。
個人開発は孤独感との戦いで、開発している最中に「これは需要がないかもしれない」という不安がよく生まれます。
そのため収益を見込んでからの開発を意識し、フィードバックをいただきながら開発を進めることで、不安を軽減でき、リリースまでのモチベーションを保つことができました。
また、新機能を開発する際も、機能を完全にブラッシュアップする前にリリースしてみて、フィードバックを元に改善していくことを強く意識しました。

運用コストの最適化

運用コストをできるだけ抑えるため、サーバーは必要に応じて起動するような構成としました。
また、データベースについても無料枠のある Firebase, Supabase を使用し、運用コストを抑えるようにしました。

BigQueryのコスト最適化

デイトレ用のサービスについては、保存するデータ量が膨大のため、BigQuery を使用し、データの保存、分析をしました。
BigQuery はデータの保存、分析には非常に優れたサービスですが、クエリを実行するたびにコストが発生するため、クエリの最適化を意識しました。
具体的には、以下のようなコスト最適化を各所で行いました。

  • クエリの実行回数を減らすため、クエリの結果をキャッシュ
  • 初回アクセス時にフルデータを取得し、その後は差分のみ取得

生成AIを使った適時開示の要約精度向上

開発中は Gemini の最新バージョンが 1.5 で、要約精度に課題がありました。
例えば、「△2%」のような表記が「+2%」と解釈されることがあり、誤った情報が表示されてしまうことがありました。
2025 年 2 月に Gemini 2.0 がリリースされ、適時開示要約の精度が向上しました。
また、プロンプトを調整することで要約のボリュームや内容、精度を向上させることができました。

SEO 対策

Google 検索からのアクセスも想定していたため、SEO 対策を意識しました。
具体的には、Next.js の App Router を使用し、クローラーが正しくページをクロールできるようにしました。

OGP

X や Facebook などの SNS でリンクを共有した際に、OGP 画像表示されるようにしました。
Next.js(App Router)では動的に OGP 画像のテキストを変更できるため、フル活用しました。

例えば、銘柄の需給情報をグラフで表示するページの OGP 画像は、銘柄コードをパラメータとして受け取り、その銘柄の情報を表示するようにできます。

import fs from 'fs';
import path from 'path';
import { headers } from 'next/headers';
import { ImageResponse } from 'next/og';
import { IssuesCodeParams } from '@/app/_types/issuesCode';
import { getTrpcServer } from '@/app/_utils/trpcServer';

export const size = {
  width: 1200,
  height: 630,
};

export const contentType = 'image/jpg';

interface IssuesCodeProps {
  params: IssuesCodeParams;
}

export default async function Image({ params }: IssuesCodeProps) {
  const response = await getTrpcServer(await headers()).<銘柄情報取得関数>.query(
    `${params.code}0`,
  );

  const notoRegular = fs.promises.readFile(
    path.resolve('public', 'fonts', 'NotoSansJP-Regular.ttf'),
  );
  const notoBold = fs.promises.readFile(
    path.resolve('public', 'fonts', 'NotoSansJP-Bold.ttf'),
  );

  return new ImageResponse(
    (
      <div
        style={{
          backgroundImage: `url('${process.env.NEXT_PUBLIC_SITE_URL}/images/issues_ogp_base.jpg')`,
          flexDirection: 'column',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          textAlign: 'center',
          padding: 80,
        }}
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            fontSize: 48,
            fontFamily: 'NotoRegular',
            marginTop: 80,
            letterSpacing: 6,
          }}
        >
          {params.code}
        </div>
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            fontSize: 60,
            fontFamily: 'NotoBold',
          }}
        >
          {response.data?.company_name}
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [
        {
          name: 'NotoBold',
          data: await notoBold,
          style: 'normal',
          weight: 700,
        },
        {
          name: 'NotoRegular',
          data: await notoRegular,
          style: 'normal',
          weight: 400,
        },
      ],
    },
  );
}

Discussion