【第2回】htmx でSPA風ダッシュボードを作る
npm install さようなら — FastAPI + htmx でトレーディングシステムを作る

こんにちは、このシリーズでは FastAPI + htmx を使って、日経225先物・オプションのリアルタイムトレーディングダッシュボードをゼロから構築していきます。
第2回となる今回は、htmx を使ってSPA(Single Page Application)風のダッシュボードを作っていきます。React や Vue を使わず、HTML属性をいくつか書くだけで「ページ遷移なし・部分更新・リアルタイムポーリング」を実現する方法を、実際のコードを写経しながら学んでいきましょう。
このシリーズについて
本シリーズは全8回構成で、トレーディングダッシュボードを段階的に構築していきます。
| 回 | タイトル |
|---|---|
| 1 | 環境構築とプロジェクト設計 |
| 2 | htmx でSPA風ダッシュボードを作る(本記事) |
| 3 | ダークテーマUIとコンポーネント設計 |
| 4 | DB設計とAlembicマイグレーション |
| 5 | 証券APIとの接続 |
| 6 | トレーディングエンジン -- 司令塔の設計 |
| 7 | 戦略とリスク管理 |
| 8 | バックテストと本番デプロイ |
目次
- なぜ SPA フレームワークではなく htmx なのか
- htmx の基本 -- 4つの属性を覚えるだけ
- base.html -- すべてのページの土台を作る
- Jinja2テンプレートの分割戦略
- ポーリングによるリアルタイム更新
- タブ切替でセクションを表示する
- FastAPI側のエンドポイント設計 -- パーシャルレンダリングの実装
- 実践: ダッシュボードを段階的に組み上げる
- まとめ
1. なぜ SPA フレームワークではなく htmx なのか
ダッシュボードと聞くと「React + Next.js」「Vue + Nuxt」あたりが真っ先に浮かぶかもしれません。しかし今回のプロジェクトでは、あえて htmx を採用しています。その理由を整理してみましょう。
htmx を選ぶメリット
| 観点 | React / Vue | htmx |
|---|---|---|
| 学習コスト | JSX, hooks, 状態管理, ビルドツール... | HTML属性を4つ覚えるだけ |
| ビルドステップ | webpack / Vite 必須 | 不要(CDNで読み込むだけ) |
| バックエンドとの二重実装 | API は JSON を返し、フロントで組み立て | サーバーが HTML を返す。テンプレート1箇所で完結 |
| バンドルサイズ | React本体だけで ~40KB gzip | htmx は ~14KB gzip |
| Python エンジニアとの親和性 | フロントエンド専門知識が必要 | Jinja2 テンプレートだけで完結 |
htmx の思想: "HTML を拡張する"
htmx のコンセプトは非常にシンプルです。
「どの HTML 要素でも HTTP リクエストを発行できるようにし、レスポンスの HTML でページの一部を差し替える」
つまり、<a> と <form> だけが HTTP リクエストを送れるという HTML の制限を取り払い、<button> でも <div> でも <span> でも、あらゆる要素から GET / POST / PUT / DELETE を発行できるようにするライブラリです。
サーバーは JSON ではなく HTML の断片(パーシャル) を返します。これを htmx が受け取って、ページ内の指定した要素を差し替える。たったこれだけの仕組みで、SPA風のインタラクションが実現できてしまいます。
もちろん htmx にも限界はあります。複雑なクライアントサイドの状態管理が必要なケース(ドラッグ&ドロップ、リッチテキストエディタなど)では React/Vue の方が適しています。しかし「データを取得して表示する」が主な用途であるダッシュボードには、htmx は最高にフィットするアプローチです。
2. htmx の基本 -- 4つの属性を覚えるだけ
htmx を使いこなすために覚えるべき属性は、基本的に4つだけです。
hx-get / hx-post -- HTTPリクエストを発行する
<!-- GETリクエストを送る -->
<div hx-get="/api/positions">読込中...</div>
<!-- POSTリクエストを送る -->
<button hx-post="/api/trading/start">起動</button>
hx-get を付けた要素は、トリガー(後述)に応じて指定URLへGETリクエストを送ります。hx-post なら POST です。hx-put, hx-delete もあります。
hx-target -- レスポンスをどこに入れるか
<button hx-post="/api/trading/start"
hx-target="#engine-status">
起動
</button>
<div id="engine-status">ここが差し替わる</div>
hx-target を省略すると、リクエストを送った要素自身が差し替え対象になります。CSSセレクタ形式で別の要素を指定できるのがポイントです。
hx-swap -- どうやって差し替えるか
<div hx-get="/api/positions"
hx-swap="innerHTML">
</div>
hx-swap で差し替え方法を指定します。主な値は以下の通りです。
| 値 | 動作 |
|---|---|
innerHTML(デフォルト) |
ターゲット要素の中身を差し替え |
outerHTML |
ターゲット要素ごと差し替え |
beforeend |
ターゲット要素の末尾に追加 |
afterbegin |
ターゲット要素の先頭に追加 |
none |
DOM変更なし(サイドエフェクトだけ実行したい場合) |
hx-trigger -- いつリクエストを送るか
<!-- ページ読み込み時 + 5秒ごとにポーリング -->
<div hx-get="/api/trading/status"
hx-trigger="load, every 5s">
</div>
<!-- クリック時(デフォルト) -->
<button hx-post="/api/trading/start"
hx-trigger="click">
起動
</button>
hx-trigger はイベント名を指定します。load(読み込み時)、click(クリック時)、every Ns(N秒ごと)、intersect(ビューポートに入った時)などが使えます。カンマ区切りで複数指定もできます。
この4つの属性を組み合わせるだけで、非同期通信による部分更新が実現できます。JavaScript を1行も書く必要がありません。
htmx の基本的なデータフローは次の通りです。ブラウザ上の要素が hx-get などでFastAPIへHTTPリクエストを送り、FastAPIがHTMLパーシャルをレスポンスとして返却し、htmxが hx-target で指定された要素の中身を差し替えます。
3. base.html -- すべてのページの土台を作る
それでは実際にコードを書いていきましょう。まずは全ページ共通のベーステンプレートからです。
最小限の base.html を作る
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>日経225 システムトレード</title>
<!-- htmx を CDN から読み込む(これだけ!) -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<header>
<h1>日経225 システムトレード</h1>
<p class="subtitle">先物デイトレード + オプション売り + デルタヘッジ</p>
</header>
<div class="app-layout">
{% block sidebar %}{% endblock %}
<main class="content">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
ここでのポイントは3つです。
- htmx はCDNから1行で読み込む --
<script src="https://unpkg.com/htmx.org@2.0.4"></script>だけ。npm install も webpack も不要です - Jinja2 のブロック定義 --
{% block sidebar %}と{% block content %}の2つのブロックを用意。子テンプレートでオーバーライドします - レイアウト構造 --
.app-layoutで sidebar + main の2カラムレイアウトを定義
拡張ライブラリの追加
実際のプロジェクトでは、フォームデータをJSON形式で送信するための拡張と、チャート描画用の Plotly も読み込んでいます。
<head>
<!-- ... -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
htmx-ext-json-enc は、htmx のフォーム送信でデフォルトの application/x-www-form-urlencoded ではなく application/json で送信するための公式拡張です。FastAPI の BaseModel(Pydantic)でリクエストボディを受け取る場合に必要になります。
また、base.html の末尾にはチャート描画用のヘルパー関数も定義しています。
<script>
function renderChart(elementId, chartJson) {
if (!chartJson || chartJson === '{}') return;
try {
const data = JSON.parse(chartJson);
Plotly.newPlot(elementId, data.data, data.layout, {responsive: true});
} catch(e) {
console.error('Chart render error:', e);
}
}
</script>
この renderChart 関数は、バックテスト結果のチャートをPlotlyで描画するために使います。サーバーサイドで Plotly の JSON を生成し、テンプレートに埋め込んで、クライアント側でレンダリングする設計です。
4. Jinja2テンプレートの分割戦略
テンプレートが1ファイルに何百行もあると、メンテナンスが大変です。ここでは「ベース → ページ → コンポーネント」の3層に分割する戦略を解説します。
テンプレートの階層構造
templates/
base.html ... 全ページ共通のレイアウト(HTML骨格)
dashboard.html ... ダッシュボードページ本体
index.html ... バックテスト専用ページ(旧版)
components/
engine_status.html ... エンジン状態(パーシャル)
pnl.html ... 損益サマリ(パーシャル)
positions.html ... ポジション一覧(パーシャル)
orders.html ... 注文一覧(パーシャル)
signals.html ... シグナル / 戦略制御(パーシャル)
wallet_futures.html ... 先物口座余力(パーシャル)
wallet_options.html ... OP口座余力(パーシャル)
backtest.html ... バックテスト結果(パーシャル)
trading_plans.html ... リスクプラン選択UI(パーシャル)
この階層では、base.html が全体のHTML骨格を提供し、dashboard.html がそれを継承してページ構成を定義し、components/ 配下の各パーシャルテンプレートが htmx によって動的に差し替えられる部品として機能します。
3層構造の役割分担
第1層: base.html(骨格)
HTML の <head>, <header>, レイアウト構造を定義。全ページで共有されます。{% block sidebar %} と {% block content %} の差し替えポイントを提供します。
第2層: dashboard.html(ページ)
{% extends "base.html" %} でベースを継承し、サイドバーのナビゲーションとメインコンテンツのタブ構造を定義。ここで htmx のポーリング設定やターゲットIDの「枠」を用意します。
第3層: components/*.html(パーシャル)
htmx のレスポンスとしてサーバーから返される HTML断片 です。<html> や <head> を含まず、コンポーネントの中身だけを持ちます。これが htmx + FastAPI の核心部分です。
なぜコンポーネントを分離するのか
この分離は htmx のパーシャルレンダリングと直結しています。
<!-- dashboard.html 側: 「枠」を定義 -->
<div id="positions-container"
hx-get="/api/positions"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
<!-- components/positions.html: 「中身」を返す -->
{% if positions %}
<table>
<thead>
<tr>
<th>銘柄</th><th>売買</th><th>数量</th>
<th>平均価格</th><th>現在価格</th><th>含み損益</th>
</tr>
</thead>
<tbody>
{% for pos in positions %}
<tr>
<td>{{ pos.symbol }}</td>
<td>{{ '買' if pos.side.value == 'buy' else '売' }}</td>
<td>{{ pos.qty }}</td>
<td>{{ "{:,.0f}".format(pos.avg_price) }}</td>
<td>{{ "{:,.0f}".format(pos.current_price) }}</td>
<td class="{{ 'profit' if pos.unrealized_pnl >= 0 else 'loss' }}">
{{ "{:+,.0f}".format(pos.unrealized_pnl) }}円
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">ポジションなし</p>
{% endif %}
dashboard.html が初回ロードされると、hx-trigger="load" により /api/positions へGETリクエストが飛びます。FastAPI が components/positions.html をレンダリングしてHTMLを返し、htmx が #positions-container の中身を差し替える。以降は every 10s で自動更新が続きます。
この仕組みにより、ページ全体の再読み込みなしに、各コンポーネントが独立して更新されるのです。
5. ポーリングによるリアルタイム更新
トレーディングダッシュボードでは、ポジションや損益がリアルタイムに変化します。htmx なら hx-trigger="every Ns" を書くだけでポーリングが実現できます。
各コンポーネントのポーリング間隔
実際の dashboard.html では、コンポーネントの重要度に応じてポーリング間隔を変えています。
<!-- エンジン状態: 5秒ごと(重要度: 高) -->
<div id="engine-status"
hx-get="/api/trading/status"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
<!-- シグナル: 5秒ごと(重要度: 高) -->
<div id="signals-container"
hx-get="/api/signals"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
<!-- ポジション / 注文 / 損益 / 余力: 10秒ごと(重要度: 中) -->
<div id="positions-container"
hx-get="/api/positions"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
<!-- リスクプラン: 初回のみ(ポーリング不要) -->
<div id="plans-container"
hx-get="/api/trading/plans"
hx-trigger="load"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
| コンポーネント | 間隔 | 理由 |
|---|---|---|
| エンジン状態 | 5秒 | 起動/停止/エラーを素早く反映したい |
| シグナル | 5秒 | トレード判断の根拠をリアルタイムに確認 |
| ポジション | 10秒 | 約定後にすぐ反映されれば十分 |
| 注文一覧 | 10秒 | 注文状態の変化を適度に追跡 |
| 損益 | 10秒 | 値動きに応じた含み損益の変化を確認 |
| 口座余力 | 10秒 | 証拠金の変動はやや緩やかでOK |
| リスクプラン | 初回のみ | ユーザーの操作で再計算するため自動更新は不要 |
intersect トリガーで遅延読み込み
オプションタブの口座余力のように、初期表示では見えないコンポーネントには intersect トリガーが便利です。
<!-- ビューポートに入った時に初回読み込み + 以降10秒ごと -->
<div id="wallet-options-container"
hx-get="/api/wallet/options"
hx-trigger="intersect once, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
intersect once は、要素が画面に表示された時に1度だけリクエストを送ります。タブが切り替わって要素が見えるようになった瞬間に初めてデータを取得するので、初期ロードの無駄なリクエストを減らせます。
ポーリング設計のコツ
ここで一点注意が必要なのは、ポーリング間隔を短くしすぎるとサーバーへの負荷が高くなることです。設計のポイントをまとめます。
- 重要度に応じて間隔を分ける -- 全コンポーネントを一律1秒ポーリングにするのではなく、5秒/10秒/手動のように差をつける
- 非表示タブは
intersectを使う -- 見えていないコンポーネントに無駄なリクエストを送らない - 初回は
loadを併用 -- ページ読み込み直後にデータを表示するためload, every 10sのようにカンマ区切りで指定 - レスポンスは軽いHTMLパーシャル -- JSONを返してクライアントで組み立てるより、サーバーサイドレンダリングの方がレスポンスサイズもパース負荷も小さい
6. タブ切替でセクションを表示する
ダッシュボードには「先物ライブ」「オプション」「リスクプラン」「バックテスト」の4つのタブがあります。タブ切替は最小限のJavaScriptで実現しています。
サイドバーナビゲーション
{% block sidebar %}
<nav class="sidebar">
<div class="sidebar-nav">
<button class="nav-item active" onclick="switchTab('live')">先物ライブ</button>
<button class="nav-item" onclick="switchTab('options')">オプション</button>
<button class="nav-item" onclick="switchTab('plan')">リスクプラン</button>
<button class="nav-item" onclick="switchTab('backtest')">バックテスト</button>
</div>
</nav>
{% endblock %}
タブコンテンツの構造
各タブは id="tab-XXX" という <div> で、.tab-content クラスを持ちます。.active クラスが付いているタブだけが表示されます。
<!-- ライブタブ(初期表示) -->
<div id="tab-live" class="tab-content active">
<!-- エンジン制御、損益、ポジション、注文、シグナル -->
</div>
<!-- オプションタブ -->
<div id="tab-options" class="tab-content">
<!-- OP余力、OPポジション -->
</div>
<!-- リスクプランタブ -->
<div id="tab-plan" class="tab-content">
<!-- プラン選択UI -->
</div>
<!-- バックテストタブ -->
<div id="tab-backtest" class="tab-content">
<!-- バックテストフォーム、最適化フォーム -->
</div>
切替ロジック(JavaScript 7行)
function switchTab(name) {
// 全ナビボタンからactiveを除去
document.querySelectorAll('.nav-item').forEach(t => t.classList.remove('active'));
// 全タブコンテンツからactiveを除去
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
// 選択されたタブをactive化
document.getElementById('tab-' + name).classList.add('active');
event.target.classList.add('active');
}
ここで「タブ切替も htmx でやればいいのでは?」と思うかもしれませんが、この部分はあえてシンプルなJavaScriptにしています。理由は以下の通りです。
- タブ切替はサーバーとの通信が不要(DOM の class を切り替えるだけ)
- 全タブの内容は初回ロード時に DOM に含まれている(htmx のポーリング先は各コンポーネント内で個別に設定済み)
- htmx で実装すると、タブ切替のたびにサーバーリクエストが走り、不要な遅延が発生する
htmx はサーバーとの通信が必要な場面で使い、クライアント内で完結する操作は素のJavaScriptで書く -- これが htmx プロジェクトの鉄則です。
7. FastAPI側のエンドポイント設計 -- パーシャルレンダリングの実装
htmx のフロントエンドと対になるのが、FastAPI のエンドポイントです。ここではパーシャルレンダリングのパターンを見ていきましょう。
ルーターの基本構成
"""Webダッシュボード用エンドポイント"""
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
APIRouter(prefix="/api") で全エンドポイントに /api プレフィックスを付けています。htmx 側の hx-get="/api/positions" と対応します。
パターン1: GETでデータ取得 → パーシャル返却
最も基本的なパターンです。ポジション一覧の例を見てみましょう。
@router.get("/positions", response_class=HTMLResponse)
async def get_positions(request: Request):
"""ポジション一覧"""
templates: Jinja2Templates = request.app.state.templates
app = request.app
positions = []
if hasattr(app.state, "broker"):
try:
positions = await app.state.broker.get_positions()
except Exception as e:
logger.error("ポジション取得エラー: %s", e)
return templates.TemplateResponse(
"components/positions.html",
{"request": request, "positions": positions},
)
ここで重要なポイントが2つあります。
response_class=HTMLResponse-- FastAPI にレスポンスがHTMLであることを明示。デフォルトの JSON ではなく HTML を返すため、htmx が正しく処理できますTemplateResponseでcomponents/配下のテンプレートを返す -- ページ全体(dashboard.html)ではなく、コンポーネント単体のテンプレートを返します。これがパーシャルレンダリングです
パターン2: POSTで操作実行 → 状態を反映したパーシャル返却
エンジンの起動/停止のように、操作を実行して結果を返すパターンです。
@router.post("/trading/start", response_class=HTMLResponse)
async def start_trading(request: Request):
"""トレーディングエンジン起動"""
templates: Jinja2Templates = request.app.state.templates
app = request.app
engine: TradingEngine | None = getattr(app.state, "trading_engine", None)
if engine and engine.state == EngineState.RUNNING:
return templates.TemplateResponse(
"components/engine_status.html",
{"request": request, "status": engine.get_status()},
)
try:
engine = TradingEngine(app.state.config)
await engine.start()
app.state.trading_engine = engine
except Exception as e:
logger.error("エンジン起動失敗: %s", e)
status = {"state": "error", "error": str(e), ...}
return templates.TemplateResponse(
"components/engine_status.html",
{"request": request, "status": status},
)
return templates.TemplateResponse(
"components/engine_status.html",
{"request": request, "status": engine.get_status()},
)
POSTエンドポイントでも、レスポンスは同じ components/engine_status.html パーシャルです。htmx 側では hx-target="#engine-status" で差し替え先を指定しているので、ボタンを押した結果がそのまま画面に反映されます。
ここに対応する dashboard.html のボタン定義を見てみましょう。
<button class="btn btn-start"
hx-post="/api/trading/start"
hx-target="#engine-status"
hx-swap="innerHTML"
hx-confirm="トレーディングエンジンを起動しますか?"
hx-indicator="#engine-spinner">
起動
</button>
hx-confirm は、リクエスト送信前に確認ダイアログを表示する便利な属性です。ブラウザネイティブの confirm() が呼ばれるので、誤操作を防止できます。
hx-indicator は、リクエスト送信中にローディングスピナーを表示するための属性です。指定した要素に .htmx-request クラスが付与されるので、CSSで表示制御できます。
パターン3: POSTでフォームデータ(JSON)を受け取る
バックテストのように、複数のパラメータを送信するパターンです。
from pydantic import BaseModel
class BacktestRequest(BaseModel):
start_date: str = "2023-01-01"
end_date: str = "2024-12-31"
initial_capital: float = 1_000_000
multiplier: int = 100
sma_short: int = 5
sma_long: int = 25
rsi_period: int = 14
rsi_oversold: int = 30
rsi_overbought: int = 70
@router.post("/backtest", response_class=HTMLResponse)
async def backtest(request: Request, body: BacktestRequest):
"""バックテスト実行"""
templates: Jinja2Templates = request.app.state.templates
df = fetch_nikkei225(start=body.start_date, end=body.end_date)
if df.empty:
return templates.TemplateResponse(
"components/backtest.html",
{"request": request, "error": "データ取得に失敗しました"},
)
result = run_backtest(
df,
initial_capital=body.initial_capital,
multiplier=body.multiplier,
sma_short=body.sma_short,
sma_long=body.sma_long,
rsi_period=body.rsi_period,
rsi_oversold=body.rsi_oversold,
rsi_overbought=body.rsi_overbought,
)
equity_chart = create_equity_chart(result.dates, result.equity_curve)
trade_chart = create_trade_chart([asdict(t) for t in result.trades])
return templates.TemplateResponse(
"components/backtest.html",
{
"request": request,
"result": result,
"equity_chart": equity_chart,
"trade_chart": trade_chart,
"trades": result.trades,
},
)
Pydantic の BaseModel でリクエストボディを定義し、バリデーションとデシリアライゼーションを自動で行っています。htmx 側では hx-ext="json-enc" を指定して、フォームデータをJSON形式で送信します。
<form hx-post="/api/backtest"
hx-target="#backtest-results"
hx-swap="innerHTML"
hx-ext="json-enc"
hx-indicator="#bt-spinner">
<!-- フォームフィールド -->
<button type="submit" class="btn btn-primary">バックテスト実行</button>
</form>
全エンドポイントの一覧
GET /api/trading/status → engine_status.html (5秒ポーリング)
POST /api/trading/start → engine_status.html
POST /api/trading/stop → engine_status.html
GET /api/positions → positions.html (10秒ポーリング)
GET /api/orders → orders.html (10秒ポーリング)
GET /api/pnl → pnl.html (10秒ポーリング)
GET /api/signals → signals.html (5秒ポーリング)
GET /api/wallet/futures → wallet_futures.html (10秒ポーリング)
GET /api/wallet/options → wallet_options.html (10秒ポーリング)
POST /api/backtest → backtest.html
POST /api/optimize → backtest.html
GET /api/trading/plans → trading_plans.html
POST /api/trading/plans → trading_plans.html
POST /api/trading/plan/{name}/apply → trading_plans.html
すべてのエンドポイントが HTMLパーシャルを返す 点に注目してください。JSON API は1つもありません。これが htmx + FastAPI の設計パターンの特徴です。
8. 実践: ダッシュボードを段階的に組み上げる
ここからは、最小限のダッシュボードから始めて段階的に機能を追加していく流れで進めます。手を動かしながら進めてみてください。
Step 1: 最小限のダッシュボード -- エンジン状態の表示
まず dashboard.html の骨格を作り、エンジン状態のポーリングだけを実装します。
<!-- templates/dashboard.html -->
{% extends "base.html" %}
{% block content %}
<section class="card">
<h2>トレーディングエンジン</h2>
<div id="engine-status"
hx-get="/api/trading/status"
hx-trigger="load, every 5s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
{% endblock %}
これだけで、5秒ごとにエンジン状態が自動更新されるカードが表示されます。
対応するコンポーネントの engine_status.html は、状態に応じて表示を切り替えます。
<!-- templates/components/engine_status.html -->
<div class="engine-status-panel">
<div class="engine-header">
<div class="engine-indicator
{% if status.state == 'running' %}indicator-running
{% elif status.state == 'error' %}indicator-error
{% else %}indicator-stopped{% endif %}">
</div>
<span class="engine-state-label">
{% if status.state == 'running' %}稼働中
{% elif status.state == 'starting' %}起動中...
{% elif status.state == 'error' %}エラー
{% else %}停止{% endif %}
</span>
</div>
{% if status.state == 'running' %}
<div class="engine-details">
<div class="engine-detail-item">
<span class="label">アクティブ注文</span>
<span class="value">{{ status.active_orders }}件</span>
</div>
<div class="engine-detail-item">
<span class="label">本日実現損益</span>
<span class="value {% if status.realized_pnl > 0 %}profit{% elif status.realized_pnl < 0 %}loss{% endif %}">
{{ "{:,.0f}".format(status.realized_pnl) }}円
</span>
</div>
</div>
{% endif %}
{% if status.state == 'error' and status.error %}
<div class="engine-error">{{ status.error }}</div>
{% endif %}
</div>
Jinja2 の {% if %} で状態に応じたUIを出し分けている点に注目してください。稼働中なら詳細情報を表示し、エラー時にはエラーメッセージを表示する。この条件分岐がサーバーサイドで完結するのがテンプレート駆動の強みです。
Step 2: エンジン制御ボタンを追加する
次に、起動/停止ボタンを追加します。
<!-- dashboard.html に追加 -->
<div class="engine-controls">
<button class="btn btn-start"
hx-post="/api/trading/start"
hx-target="#engine-status"
hx-swap="innerHTML"
hx-confirm="トレーディングエンジンを起動しますか?"
hx-indicator="#engine-spinner">
起動
</button>
<button class="btn btn-stop"
hx-post="/api/trading/stop"
hx-target="#engine-status"
hx-swap="innerHTML"
hx-confirm="エンジンを停止しますか?(未約定注文は取消されます)">
停止
</button>
<button class="btn btn-danger"
hx-post="/api/trading/stop?close_positions=true"
hx-target="#engine-status"
hx-swap="innerHTML"
hx-confirm="全ポジションを決済してエンジンを停止しますか?この操作は取り消せません。">
全決済&停止
</button>
<span id="engine-spinner" class="htmx-indicator spinner"></span>
</div>
hx-confirm で確認ダイアログを出す、hx-indicator でスピナーを表示する、hx-post の URL にクエリパラメータを付けて挙動を変える -- htmx の属性だけでこれだけの制御ができます。
Step 3: 損益・ポジション・注文カードを並べる
<!-- 損益サマリ -->
<section class="card">
<h2>損益</h2>
<div id="pnl-container"
hx-get="/api/pnl"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
<!-- ポジション -->
<section class="card">
<h2>ポジション</h2>
<div id="positions-container"
hx-get="/api/positions"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
<!-- 注文一覧 -->
<section class="card">
<h2>注文一覧</h2>
<div id="orders-container"
hx-get="/api/orders"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
同じパターンの繰り返しですね。hx-get のURLとターゲットIDを変えるだけで、独立して更新されるカードが次々と追加できます。
対応する損益コンポーネントの pnl.html はこのようなシンプルな構造です。
<!-- templates/components/pnl.html -->
<div class="pnl-cards">
<div class="pnl-card">
<span class="pnl-label">本日実現損益</span>
<span class="pnl-value {{ 'profit' if pnl.realized >= 0 else 'loss' }}">
{{ "{:+,.0f}".format(pnl.realized) }}円
</span>
</div>
<div class="pnl-card">
<span class="pnl-label">含み損益</span>
<span class="pnl-value {{ 'profit' if pnl.unrealized >= 0 else 'loss' }}">
{{ "{:+,.0f}".format(pnl.unrealized) }}円
</span>
</div>
<div class="pnl-card">
<span class="pnl-label">合計</span>
<span class="pnl-value {{ 'profit' if (pnl.realized + pnl.unrealized) >= 0 else 'loss' }}">
{{ "{:+,.0f}".format(pnl.realized + pnl.unrealized) }}円
</span>
</div>
</div>
profit / loss クラスで赤緑の色分けを行っています。Jinja2 の条件式 {{ 'profit' if pnl.realized >= 0 else 'loss' }} で、プラスなら緑、マイナスなら赤になります。
Step 4: シグナルと戦略制御を追加する
シグナルコンポーネントは少し特殊で、htmx の入れ子パターンが登場します。
<!-- templates/components/signals.html -->
<!-- 戦略ON/OFF制御 -->
<div class="strategy-controls">
{% for name, enabled in strategies.items() %}
<div class="strategy-toggle">
<span class="strategy-name">{{ name }}</span>
<button class="btn btn-sm {{ 'btn-on' if enabled else 'btn-off' }}"
hx-post="/api/strategy/{{ name }}/toggle"
hx-target="#signals-container"
hx-swap="innerHTML">
{{ 'ON' if enabled else 'OFF' }}
</button>
</div>
{% endfor %}
</div>
<!-- 直近シグナル一覧 -->
{% if signals %}
<table>
<thead>
<tr>
<th>時刻</th><th>戦略</th><th>銘柄</th><th>シグナル</th><th>理由</th>
</tr>
</thead>
<tbody>
{% for sig in signals[-20:]|reverse %}
<tr>
<td>{{ sig.timestamp if sig.timestamp else '-' }}</td>
<td>{{ sig.strategy if sig.strategy else '-' }}</td>
<td>{{ sig.symbol }}</td>
<td class="{{ sig.signal_type }}">{{ sig.signal_type }}</td>
<td>{{ sig.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">シグナルなし</p>
{% endif %}
ここでのポイントは、戦略のON/OFFボタン自体が htmx のリクエストを発行する点です。ボタンを押すと POST /api/strategy/{name}/toggle が呼ばれ、レスポンスのHTMLで #signals-container 全体が差し替わります。つまり、ポーリングで取得される内容の中に、さらに htmx のインタラクションが含まれているのです。
htmx はDOMの差し替え後も、新しく挿入された要素の hx-* 属性を自動的に認識するため、この入れ子パターンが自然に動作します。
Step 5: サイドバーとタブ構造を完成させる
最後にサイドバーを追加し、4タブ構成に仕上げます。
{% extends "base.html" %}
{% block sidebar %}
<nav class="sidebar">
<div class="sidebar-nav">
<button class="nav-item active" onclick="switchTab('live')">先物ライブ</button>
<button class="nav-item" onclick="switchTab('options')">オプション</button>
<button class="nav-item" onclick="switchTab('plan')">リスクプラン</button>
<button class="nav-item" onclick="switchTab('backtest')">バックテスト</button>
</div>
</nav>
{% endblock %}
{% block content %}
<!-- ライブタブ -->
<div id="tab-live" class="tab-content active">
<!-- Step 1〜4 で作ったカードたち -->
</div>
<!-- オプションタブ -->
<div id="tab-options" class="tab-content">
<section class="card">
<h2>オプション余力</h2>
<div id="wallet-options-container"
hx-get="/api/wallet/options"
hx-trigger="intersect once, every 10s"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
</div>
<!-- リスクプランタブ -->
<div id="tab-plan" class="tab-content">
<section class="card">
<h2>リスクプラン選択</h2>
<div id="plans-container"
hx-get="/api/trading/plans"
hx-trigger="load"
hx-swap="innerHTML">
<div class="loading">読込中...</div>
</div>
</section>
</div>
<!-- バックテストタブ -->
<div id="tab-backtest" class="tab-content">
<section class="card">
<h2>バックテスト</h2>
<form hx-post="/api/backtest"
hx-target="#backtest-results"
hx-swap="innerHTML"
hx-ext="json-enc"
hx-indicator="#bt-spinner">
<!-- フォームフィールド -->
<button type="submit" class="btn btn-primary">バックテスト実行</button>
<span id="bt-spinner" class="htmx-indicator spinner"></span>
</form>
</section>
<div id="backtest-results"></div>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.nav-item').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
event.target.classList.add('active');
}
</script>
{% endblock %}
これで、左にサイドバー、右にカード形式で各コンポーネントが並ぶダッシュボードの構造が完成しました。
json-enc 拡張の設定
バックテストフォームでは、hx-ext="json-enc" を使ってフォームデータをJSON形式で送信しています。この拡張のカスタム定義もプロジェクトに含まれています。
/* htmx json-enc 拡張: フォームデータをJSONとして送信 */
htmx.defineExtension('json-enc', {
onEvent: function(name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = 'application/json';
}
},
encodeParameters: function(xhr, parameters, elt) {
const obj = {};
for (const [key, value] of Object.entries(parameters)) {
// チェックボックスの真偽値変換
if (value === 'true') { obj[key] = true; }
else if (value === 'false') { obj[key] = false; }
// 数値変換
else if (!isNaN(value) && value !== '') { obj[key] = Number(value); }
else { obj[key] = value; }
}
// チェックボックスが未チェックの場合は false を設定
['use_rsi', 'use_sma', 'use_macd', 'use_bb'].forEach(function(k) {
if (!(k in obj)) { obj[k] = false; }
});
xhr.overrideMimeType('text/html');
return JSON.stringify(obj);
}
});
この拡張が行っていることは以下の3つです。
Content-Typeヘッダーをapplication/jsonに変更- フォームの文字列値を適切な型(boolean, number)に変換
- 未チェックのチェックボックスに
falseを明示的に設定
HTMLフォームのデフォルト動作では、チェックが外れたチェックボックスはフォームデータに含まれません。しかし FastAPI(Pydantic)側では use_rsi: bool のようにフィールドを定義しているため、値が送られないとバリデーションエラーになる可能性があります。この拡張がその問題を吸収してくれます。
9. まとめ
この記事で扱った内容を振り返りましょう。
- htmx は HTML属性を4つ(hx-get, hx-target, hx-swap, hx-trigger)覚えるだけで、非同期通信による部分更新が実現できる。JavaScript のフレームワーク知識は不要
- テンプレートは3層構造(base → page → components) に分割する。
components/配下のパーシャルテンプレートが htmx の差し替え単位になる - ポーリングは
hx-trigger="every Ns"だけで実装可能。コンポーネントの重要度に応じて間隔を5秒/10秒に分け、非表示タブはintersectで遅延読み込みする - FastAPI のエンドポイントはすべて HTMLResponse を返す。JSON API を作って JavaScript で組み立てる必要がなく、テンプレート1箇所でUIが完結する
- タブ切替のようなクライアント内完結の操作は素のJavaScript で書く。htmx はサーバー通信が必要な場面に使い、不要な場面では使わないのが鉄則
hx-ext="json-enc"で フォームデータをJSON送信 することで、FastAPI の Pydantic BaseModel と組み合わせた型安全なパラメータ受け渡しが実現できる
次回予告
第3回では、ダークテーマUIとコンポーネント設計 に取り組みます。今回作ったHTMLの骨格に、トレーディングダッシュボードらしいダークテーマのスタイルを適用していきます。CSS変数を使ったテーマ設計、カード/テーブル/バッジなどのコンポーネントスタイル、利益は緑・損失は赤の配色ルールなど、実用的なUIデザインを解説します。
お楽しみに。
最後まで読んでくださりありがとうございました。質問やフィードバックがあれば、コメント欄でお気軽にどうぞ。