はじめに
今回は、個人開発として制作した AI 搭載型の資産管理・分析アプリ「PortfolioX」について紹介します。
私は普段、米国株 ETF とビットコインを中心に投資を行っているのですが、既存のアプリには以下のような不満がありました。
- 管理の分断:株専用・仮想通貨専用のアプリが分かれており、資産全体を毎月手動で合算する必要があった
- AI 分析の物足りなさ:既存の AI 機能は汎用的なチャットに留まり、「自分の持っている具体的な銘柄」に基づいた深い分析や、著名投資家の視点での議論ができるサービスはなかった
そして、今回 リアルタイムの市場データ・チャート・AIチャットボットを一つのダッシュボードであるPortfolioX を開発しました。
github
サービス概要
米国株・ETF・仮想通貨を一画面で一元管理し、AI が多角的な投資判断をサポートする統合プラットフォームです。
主な機能
1. 資産管理とチャート可視化
株式・仮想通貨のリアルタイム検索・追加。ポートフォリオの総資産額と歴史的推移をグラフ表示。

2.個別株のAI分析
5 人の AI 投資家が持株構成と最新市場データを読み取り、個別診断を実行。

3.パーソナライズニュース
4.リアルタイム相場表示
5.多言語対応
日本語・英語の 2 言語に対応し、ユーザーはワンクリックで表示言語を切り替え可能。

6.テーマ切り替え
ライト・ダークに加え、質感にこだわった「シャンパン・ブラウン」の 3 テーマを搭載。

使用技術
フロントエンド
- Framework:Next.js 16+ (App Router)
- Library:React 19 / TypeScript
- Styling:Tailwind CSS v4 / shadcn/ui
- State Management:TanStack Query (React Query)
バックエンド / インフラ
- BaaS:Supabase
- Hosting:Vercel
AI / データソース
- AI Stack:Vercel AI SDK + OpenRouter (free model) + DeepSeek (deepseek-v4)
- RAG / Search:Tavily API
- Financial Data:Yahoo Finance API / TradingView Widgets / CoinGecko Widgets
技術選定理由
1. Nextjs & TypeScript
金融データの即時性を重視し、初回読み込みが遅いSPAではなく、SSRによって初回表示を高速化できるNext.jsを採用しました。
価格、日付、銘柄情報など多角的なデータを厳格に扱う必要があったため、静的型付けによって型の不一致などのエラーをコンパイル時に検出でき、金融システムにおいて致命傷となる予期せぬバグを未然に防げるTypeScriptを導入しました。
2. AI SDK & AI Model
Vercel SDK: Next.jsとの連携が一番スムーズで、DeepSeekとOpenRouterの切り替えが一行で済むから便利です。
DeepSeek: 圧倒的な低コストに加え、開発元(幻方量化)が大手クオンツファンドであるため、金融分析に強いです。また、AI投資コンテスト「Alpha Arena」でも主要LLMを抑えトップ級の成績を収めており、金融ドメインへの高い適性から採用しました。
OpenRouter: 無料枠の提供に加え、単一のAPIから多様な軽量・高性能モデルにアクセスでき、個人開発の検証コストを最小限に抑えられるため。
3. Vercel
Next.jsとの相性が良く、SSRを容易に実現できる点、GitHub連携による自動デプロイで開発サイクルを加速できる点、Vercel AI SDKとの親和性が高く、低遅延なAIストリーミング環境を構築できる点から採用しました。
4. Supabase
市場の価格変動を画面に反映するリアルタイム同期機能や認証機能を最小限の開発コストで一元管理できるため、Supabaseを採用しました。
こだわった実装・苦労した点
① AI Multi-Agent の設計と実装
本アプリの技術的な核心は、スタイルの異なる 5 人の著名投資家を模した AI Agent の中からユーザーが 1〜2 名を選択し、同じ銘柄データをもとにそれぞれ独立した分析を行い、2 名選択時はコーディネーターが総括するという Multi-Agent アーキテクチャです。
実装には Vercel AI SDK の generateText と Tool Calling を組み合わせ、SSE(Server-Sent Events) でクライアントにリアルタイム配信しています。
5 人の投資家ペルソナ
| ペルソナ | 名前 | 投資スタイル |
|---|---|---|
buffett |
Warren Buffett | 割安優良株・経済的な堀・長期保有 |
lynch |
Peter Lynch | 成長株・PEG 比率・テンバガー狙い |
wood |
Cathie Wood | 破壊的イノベーション・TAM 重視 |
burry |
Michael Burry | 逆張り・割安資産・バブル検出 |
dalio |
Ray Dalio | マクロサイクル・地政学リスク |
各ペルソナは features/ai/lib/prompts.ts の PERSONA_PROMPTS に独立したシステムプロンプトとして定義されており、JSON 形式での出力(verdict / score / points / buyRange)を強制する構造になっています。
// features/ai/lib/prompts.ts
export const PERSONA_PROMPTS: Record<string, string> = {
buffett: `You are Warren Buffett, the Oracle of Omaha.
<investment_philosophy>
- Only buy wonderful businesses at fair prices
- Require a strong economic moat: brand loyalty, network effects, switching costs
- Key metrics: ROE > 15%, debt-to-equity < 0.5, consistent free cash flow growth
- MUST have margin of safety: price at least 20% below intrinsic value
</investment_philosophy>
<output_format>
Respond ONLY with a valid JSON object:
{
"points": ["...3 specific observations..."],
"score": 68,
"verdict": "hold",
"buyRange": { "low": 195.00, "high": 210.00 }
}
</output_format>`,
// lynch / wood / burry / dalio も同様に定義(計 5 名)
};
Agent ごとに独立した generateText を Promise.all で並列実行
逐次実行だと Agent の数だけ待ち時間が積み重なりますが、Promise.all で並列化することで最も遅い 1 Agent の処理時間に収められます。
// app/api/ai-analysis/route.ts
import { deepseek } from "@ai-sdk/deepseek";
import { generateText, stepCountIs } from "ai";
import { getStockPrice } from "@/features/ai/lib/getStockPrice";
import { getFinancials } from "@/features/ai/lib/getFinancials";
import { getNews } from "@/features/ai/lib/getNews";
import { ANALYSIS_PROMPT, PERSONA_PROMPTS } from "@/features/ai/lib/prompts";
const [result1, result2] = await Promise.all([
generateText({
model: deepseek("deepseek-v4-flash"),
tools: { getStockPrice, getFinancials, getNews },
stopWhen: stepCountIs(5), // Tool → LLM の最大ループ回数
system: `${PERSONA_PROMPTS[personas[0]]}${langInstruction}`,
prompt: ANALYSIS_PROMPT(symbol),
}),
generateText({
model: deepseek("deepseek-v4-flash"),
tools: { getStockPrice, getFinancials, getNews },
stopWhen: stepCountIs(5),
system: `${PERSONA_PROMPTS[personas[1]]}${langInstruction}`,
prompt: ANALYSIS_PROMPT(symbol),
}),
]);
Tool Calling でリアルタイムの市場データを取得
Agent は分析中に以下の 3 つのツールを自律的に呼び出します。
| ツール | データソース | 取得内容 |
|---|---|---|
getStockPrice |
Yahoo Finance | リアルタイム株価・通貨 |
getFinancials |
Yahoo Finance | PER・PBR・ROE・FCF など財務指標 |
getNews |
Tavily API | 直近ニュース(最大 5 件) |
// features/ai/lib/getStockPrice.ts
import { tool } from "ai";
import { z } from "zod";
import yf from "@/lib/yahoo-finance";
export const getStockPrice = tool({
description: "Get the current real-time price of a stock or crypto or etf asset by its symbol.",
inputSchema: z.object({
symbol: z.string().describe("e.g. AAPL, BTC-USD, SPY"),
}),
strict: true,
execute: async ({ symbol }) => {
const quote = await yf.quote(symbol);
return {
symbol,
price: quote.regularMarketPrice ?? null,
currency: quote.currency ?? "USD",
};
},
});
stopWhen: stepCountIs(5) により、Tool → LLM → Tool のループを最大 5 ステップに制限しています。これにより、無限ループによるコスト爆発を防ぎつつ、必要なデータを十分に収集できるバランスを実現しています。
SSE(Server-Sent Events)で段階的にリアルタイム配信
Server Actions ではなく Route Handler + SSE を採用した理由は、Agent が完了した順にクライアントへ即座に通知できるからです。全 Agent の完了を待たずに結果を表示できるため、体感速度が大幅に向上します。
// app/api/ai-analysis/route.ts
export async function POST(request: Request) {
const stream = new ReadableStream({
async start(controller) {
const sseEvent = (event: string, data: unknown) =>
new TextEncoder().encode(
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
);
// Agent 1・2 が完了次第、それぞれ即座にイベントを送信
const [result1, result2] = await Promise.all([
generateText({ /* Agent 1 設定 */ }).then((r) => {
controller.enqueue(sseEvent("agent1_done", parseAgent(r.text, personas[0])));
return r;
}),
generateText({ /* Agent 2 設定 */ }).then((r) => {
controller.enqueue(sseEvent("agent2_done", parseAgent(r.text, personas[1])));
return r;
}),
yf.quote(symbol), // 現在株価も並列取得
]);
// 両 Agent 完了後、コーディネーターが総括
const coordinatorResult = await generateText({
model: deepseek("deepseek-v4-flash"),
providerOptions: { deepseek: { thinking: { type: "disabled" } } },
system: `${COORDINATOR_PROMPT}${langInstruction}`,
prompt: `
Agent 1 (${personas[0]}): ${JSON.stringify(agentResults[0])}
Agent 2 (${personas[1]}): ${JSON.stringify(agentResults[1])}
Synthesize and give final recommendation for ${symbol}.`,
});
controller.enqueue(sseEvent("coordinator_done", coordinator));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
クライアント側は fetch + ReadableStream で SSE を受信し、agent1_done → agent2_done → coordinator_done の各イベントが届くたびに対応するカードを段階表示します。
[クライアント] [サーバー]
| |
|── POST /api/ai-analysis ───────>|
| |── Agent 1 & Agent 2 並列実行開始
|<── event: agent1_done ──────────| (Agent 1 完了)
| → Agent 1 カード表示 |
|<── event: agent2_done ──────────| (Agent 2 完了)
| → Agent 2 カード表示 |── Coordinator 実行開始
|<── event: coordinator_done ─────| (Coordinator 完了)
| → 総括カード表示 |
② アーキテクチャの再設計 — Artifact 型分類から Feature-Sliced Design へ
リファクタリング前:Artifact 型による分類
最初は Next.js の慣習に従い、ファイルを「種類」で分類する構成を採用していました。
app/
components/
├── auth/
├── ai/
├── assets/
├── dashboard/
├── crypto/
└── stocks/
hooks/
├── useAssets.ts
├── useTransactions.ts
└── useAiAnalysis.ts
lib/
├── ai/
├── asset-calculations.ts
└── schemas/
server/
├── auth.ts
├── assets.ts
└── transactions.ts
types/
i18n/
一見整然としていますが、実際に開発を進めると 「資産管理機能を修正するために、components・hooks・lib・server・types の 5 つのディレクトリを往復する」 という状況が常態化していました。
リファクタリング後:Feature-Sliced Design
features/
├── ai/ ← AI 分析機能に関わる全てをここに集約
│ ├── components/
│ │ ├── agent-card.tsx
│ │ ├── agent-selector.tsx
│ │ ├── analysis-shell.tsx
│ │ └── coordinator-card.tsx
│ ├── lib/
│ │ ├── constants.ts
│ │ ├── getFinancials.ts ← AI Tool
│ │ ├── getNews.ts ← AI Tool
│ │ ├── getStockPrice.ts ← AI Tool
│ │ ├── prompts.ts ← 投資家ペルソナ定義
│ │ └── parse-agent.ts
│ ├── types/
│ │ └── index.ts
│ └── ARCHITECTURE.md ← このモジュールの設計ドキュメント
│
├── assets/ ← ポートフォリオ資産管理機能
│ ├── components/
│ │ ├── asset-table.tsx
│ │ ├── asset-form.tsx
│ │ └── portfolio-candlestick-chart.tsx
│ ├── hooks/
│ │ ├── use-asset-table.ts
│ │ └── use-portfolio-candlestick-chart.ts
│ ├── lib/
│ │ ├── calculations.ts
│ │ └── portfolio-ohlc.ts
│ ├── schemas/
│ │ ├── asset.ts
│ │ └── transaction.ts
│ ├── server/
│ │ ├── assets.ts
│ │ └── transactions.ts
│ ├── types/
│ │ └── index.ts
│ └── ARCHITECTURE.md
│
├── auth/ ← 認証機能
├── stocks/ ← 株式市場ウィジェット
├── crypto/ ← 暗号資産ウィジェット
└── dashboard/ ← サイドバー・ヘッダーなどレイアウト共通部品
app/
├── [locale]/dashboard/
│ ├── ai/page.tsx ← features/ai を組み合わせるだけ
│ ├── assets/page.tsx ← features/assets を組み合わせるだけ
│ └── ...
└── api/
├── ai-analysis/route.ts
└── assets/...
lib/ ← features をまたぐ純粋なユーティリティのみ
├── yahoo-finance.ts
├── news-fetcher.ts
└── supabase/
設計の原則:app/ のページは薄いオーケストレーション層に徹し、ロジックと UI はすべて features/ 配下に置く。
なぜこの構成に変えたか
| 観点 | Artifact 型(変更前) | Feature-Sliced(変更後) |
|---|---|---|
| ファイルの検索 | 機能ごとに 5 ディレクトリを行き来する |
features/ai/ を開けば全てある |
| 影響範囲の把握 | 変更が他機能に波及するか不明 | Feature をまたがない変更は安全 |
| 削除・追加 | 関連ファイルが散在し漏れやすい | Feature ディレクトリごと削除・複製できる |
「1 Feature = 1 ディレクトリ」を徹底したことで、どのファイルを見れば何が分かるかが明確になり、バグの発生場所の特定も速くなりました。
また、「この構成がHarness Engineeringに相性がいいじゃない?」 という思いが修正後に意識しました。
私のHarness Engineeringの全体像
リポジトリルート
├── CLAUDE.md ← AI が最初に読む全体像・ハードルール・ドキュメントマップ
├── INIT_CONTRACT.md ← 新セッション開始時の「事前チェック」
├── TASKS.md ← 今やるべき 1 タスク + 詳細なチェックリスト
├── CONSTRAINTS.md ← 絶対に破ってはいけないルール集(セキュリティ・スコープ・Git)
├── GOTCHAS.md ← 過去にあったバグの記録。AI が同じミスを繰り返さないために
├── progress.md ← セッション横断の進捗ログ。前の AI が何をしたか次の AI が把握できる
├── init.sh ← build + lint + test を一発検証。AI が「完了」を自己申告する前の必須儀式
└── features/
├── ai/ARCHITECTURE.md ← AI 機能モジュールの設計ドキュメント
├── assets/ARCHITECTURE.md ← 資産管理モジュールの設計ドキュメント
└── .../ARCHITECTURE.md ← 各 Feature に 1 枚ずつ
理由が以下3つあります。
① スコープを明示できる
CLAUDE.md に 「範囲外のファイルは変更しない」 という制約を書くことができ、AI エージェントに「今回のタスクは features/assets/ のみ」と明確に指示できます。Artifact 型だと「components/assets/ と hooks/ と lib/ と server/ をまとめて変更してください」という曖昧な指示になります。
② 各 Feature に ARCHITECTURE.md を置く
features/
├── ai/ARCHITECTURE.md ← AI 機能の責務・外部依存・変更禁止箇所を記載
├── assets/ARCHITECTURE.md
└── auth/ARCHITECTURE.md
AI エージェントがタスクに取り組む前にこのドキュメントを読むことで、設計意図を逸脱した実装を防ぐことができます。
③ 変更の影響範囲が Feature 内に閉じる
Feature をまたぐ依存を持たない設計にしているため、AI エージェントが features/ai/ を書き換えても features/assets/ には影響しません。これにより、AI の変更がデグレードを起こすリスクを構造的に最小化できます。
作成期間
合計:約 3 ヶ月
| 期間 | 内容 |
|---|---|
| 最初の 2 週間 | 設計(User Story / Flow / UI デザイン) |
| 残り 2.5 ヶ月 | 実装・デバッグ |
運用して気づいた課題
1. Yahoo Finance API の破壊的変更への対応
ある日突然 /api/yahoofinance/search が 500 エラーを返し始めました。
原因:調査すると Yahoo Finance がレスポンス構造を変更しており、使用していた yahoo-finance2 v3.13.2 がスキーマ不一致でクラッシュしていました。
対処:v3.15.2 へアップグレードし、バリデーションエラー時は空配列を返すフォールバックも追加
2. 外部 API のコスト管理 — Supabase キャッシュ層の導入
資産ページを開くたびに Tavily ニュース API を叩く設計だったため、ページ遷移のたびにコストが発生していました。
採用した解決策:Supabase の news_cache テーブルで JST 日付単位キャッシュ
初回アクセス or 日付変更後 → Tavily を呼び出しキャッシュ書き込み
2 回目以降(同日) → キャッシュから返却(Tavily 呼び出しなし)
手動更新ボタン → force=true で Tavily を強制呼び出し、キャッシュ更新
3. AI の出力言語が制御できない問題
locale=ja(日本語)のユーザーに対して、AI が意図しない別の言語で応答するバグが発生しました。
原因:system prompt の構造ヘッダーを英語以外の言語で書いていたため、モデルが言語指示より先にプロンプトの言語スタイルを優先して学習してしまっていた。
対処:
// 修正前:構造ヘッダーが英語以外 → モデルがその言語と判断
`## HOLDINGS SNAPSHOT(別言語表記)
${langInstruction}`
// 修正後:ヘッダーは常に英語 + 言語指示に CRITICAL: を付与
`## HOLDINGS SNAPSHOT
CRITICAL: You MUST respond in Japanese (ja). Do not use any other language.`
無料モデルは言語指示より「プロンプト内の言語スタイル」を優先する傾向があります。system prompt の構造部分は常に英語で書き、言語指示は CRITICAL: で強調するというルールを GOTCHAS.md に記録し、以後のセッションで再発を防いでいます。
今後の展望
- 著名投資家のポートフォリオ追跡:有名な投資家や KOL が「今、何を売買しているか」をアプリ内でチェックできる機能
- AI 機能の深化:現在の診断機能に加え、より深い市場予測やリスクアラート機能を模索中
おわりに
初めての本格的なフルスタック個人開発でしたが、自分のアイデアが形になる過程は最高に楽しい時間でした。UI 設計から AI のプロンプト調整まで、全てを一人で決める大変さはありましたが、その分多くの学びがありました。
特に Multi-Agent の並列ストリーミング設計は苦労しました。「なぜ順番に実行すると遅くなるのか」「SSE でどう流せばいいのか」と何度も詰まりながら、動いた瞬間の達成感はひとしおでした。
1 年後にこのコードを見返した時に「なんて未熟なコードなんだ」と笑えるくらい、今後も学習と開発を続けていきたいと思います。
最後までお読みいただき、ありがとうございました!


