Bitcoin自動取引システムを作ってみた
Bitcoin自動取引システムを作ってみた - GMO/Bitflyer連携でリアルタイム取引エンジン構築
TL;DR
- GMOコイン・BitflyerのWebSocket APIと連携したBitcoin自動取引システムを開発
- Java 17 + Spring Boot による高性能取引エンジン「ExchSim」
- React 19 + TypeScript によるモダンなトレーディングUI
- 実市場データを使ったアービトラージ戦略で微益を確認
- フルスタック構成でバックテストから本番まで一貫した環境
はじめに
「本格的なアルゴリズム取引システムを自作してみたい」という思いから、構想2年、実作業は、休日と平日1−2時間で、約半年ほどでBitcoinの自動取引システムを開発しました。
単なる価格監視ツールではなく、実際の取引所APIから市場データを取得し、独自の約定エンジンで注文処理を行い、リアルタイムUIで可視化する——そんな商業グレードのトレーディングシステムを目指して開発しています。まだ、バグはあります。
今回はシステムの技術的詳細と、開発で直面した課題・解決策を共有します。
システム全体アーキテクチャ
コンポーネント概要
コンポーネント | 技術スタック | 主な責務 |
---|---|---|
BitcoinMarketRecorder | Java 17 + Spring Boot + WebSocket | 市場データ収集・統合 |
ExchSim | Java 17 + Spring Boot + H2 + JWT | 注文処理・約定エンジン |
TradingScreen | React 19 + TypeScript + Vite | UI・可視化 |
1. BitcoinMarketRecorder - データ収集層の実装
WebSocket接続管理
GMOコインとBitflyerの両方のWebSocket APIに同時接続し、リアルタイムで市場データを取得します。
@Component
public class MarketDataCollector {
@Autowired
private GmoWebSocketClient gmoClient;
@Autowired
private BitflyerWebSocketClient bitflyerClient;
@PostConstruct
public void startDataCollection() {
// 1秒間隔でのスケジュール実行
scheduler.scheduleAtFixedRate(this::collectMarketData, 0, 1, TimeUnit.SECONDS);
}
private void collectMarketData() {
// GMOから板情報・約定データ取得
gmoClient.requestOrderBook("BTC", "BTC_JPY");
gmoClient.requestExecutions("BTC", "BTC_JPY");
// Bitflyerから板情報・約定データ取得
bitflyerClient.requestOrderBook("BTC_JPY", "FX_BTC_JPY");
bitflyerClient.requestExecutions("BTC_JPY", "FX_BTC_JPY");
}
}
シンボルマッピングの実装
取引所ごとに異なるシンボル体系を統一するため、設定ベースのマッピング機能を実装:
# application.yml
exch-sim:
symbol-mapping:
GMO:
BTC: G_FX_BTCJPY # GMO先物
BTC_JPY: G_BTCJPY # GMO現物
ETH_JPY: G_ETHJPY # GMO ETH現物
BITFLYER:
BTC_JPY: B_BTCJPY # Bitflyer現物
FX_BTC_JPY: B_FX_BTCJPY # Bitflyer CFD
@ConfigurationProperties(prefix = "exch-sim.symbol-mapping")
@Data
public class SymbolMappingConfig {
private Map<String, Map<String, String>> exchanges = new HashMap<>();
public String getExchSimSymbol(String exchange, String originalSymbol) {
return exchanges.get(exchange).get(originalSymbol);
}
}
自動再接続機能
WebSocket接続の安定性確保のため、切断検知と自動再接続を実装:
@EventListener
public void handleWebSocketDisconnect(SessionDisconnectEvent event) {
String exchange = getExchangeFromSession(event.getSessionId());
log.warn("WebSocket切断検知: {}", exchange);
// 指数バックオフによる再接続
scheduleReconnectWithBackoff(exchange, 1);
}
private void scheduleReconnectWithBackoff(String exchange, int attempt) {
long delay = Math.min(1000 * (long) Math.pow(2, attempt), 30000); // 最大30秒
CompletableFuture.delayedExecutor(delay, TimeUnit.MILLISECONDS)
.execute(() -> {
try {
reconnectWebSocket(exchange);
log.info("再接続成功: {}", exchange);
} catch (Exception e) {
log.error("再接続失敗: {}, attempt: {}", exchange, attempt);
scheduleReconnectWithBackoff(exchange, attempt + 1);
}
});
}
2. ExchSim - 高性能取引エンジンの実装
銘柄別ロックによる並行処理最適化
複数の注文が同時に来た場合の整合性を保つため、銘柄ごとにロックを管理:
@Service
public class OrderProcessingService {
private final ConcurrentHashMap<String, ReentrantLock> symbolLocks = new ConcurrentHashMap<>();
public ExecutionResult processOrder(Order order) {
ReentrantLock lock = symbolLocks.computeIfAbsent(
order.getSymbol(),
k -> new ReentrantLock()
);
lock.lock();
try {
return executeOrderMatching(order);
} finally {
lock.unlock();
}
}
private ExecutionResult executeOrderMatching(Order order) {
MarketBoard board = marketBoardService.getBoard(order.getSymbol());
if (order.getOrderType() == OrderType.MARKET) {
return processMarketOrder(order, board);
} else {
return processLimitOrder(order, board);
}
}
}
現物・先物の取引制限実装
現物取引では空売りを禁止し、先物取引では制限なしとする業務ロジック:
@Component
public class TradingRuleEngine {
public void validateOrder(Order order) {
Instrument instrument = instrumentService.getInstrument(order.getSymbol());
if (instrument.getType() == InstrumentType.CASH && order.getSide() == Side.SELL) {
validateCashSellOrder(order);
}
// FX商品は制限なし
}
private void validateCashSellOrder(Order order) {
Position position = positionService.getPosition(order.getSymbol(), order.getUserId());
BigDecimal availableQty = position.getNetQuantity();
if (availableQty.compareTo(order.getQuantity()) < 0) {
throw new InsufficientPositionException(
String.format("保有ポジション不足. 保有: %s, 注文: %s",
availableQty, order.getQuantity())
);
}
}
}
MarketMaker機能のアトミック処理
既存注文のキャンセルと新規注文投入を不可分で実行:
@Service
@Transactional
public class MarketMakeService {
public MarketMakeResult updateMarketMakeOrders(MarketMakeRequest request) {
String symbol = request.getSymbol();
String userId = request.getUserId();
// 1. 既存注文を全てキャンセル
List<Order> cancelledOrders = cancelAllUserOrders(symbol, userId);
// 2. 新規注文を一括投入
List<Order> newBidOrders = createBidOrders(request.getBidLevels(), symbol, userId);
List<Order> newAskOrders = createAskOrders(request.getAskLevels(), symbol, userId);
// 3. 板情報を更新
updateMarketBoard(symbol, newBidOrders, newAskOrders);
return MarketMakeResult.builder()
.cancelledOrdersCount(cancelledOrders.size())
.newBidOrdersCount(newBidOrders.size())
.newAskOrdersCount(newAskOrders.size())
.build();
}
}
約定履歴の永続化とページネーション
H2データベースを使用した効率的な約定履歴管理:
@Entity
@Table(name = "executions")
public class ExecutionEntity {
@Id
private String execId;
@Column(name = "cl_ord_id")
private String clOrdId;
private String symbol;
private String username;
@Enumerated(EnumType.STRING)
private ExecutionStatus execStatus;
@Column(name = "last_px", precision = 19, scale = 8)
private BigDecimal lastPx;
@Column(name = "last_qty", precision = 19, scale = 8)
private BigDecimal lastQty;
@CreationTimestamp
private LocalDateTime createdAt;
}
@Repository
public interface ExecutionRepository extends JpaRepository<ExecutionEntity, String> {
@Query("SELECT e FROM ExecutionEntity e WHERE e.username = :username " +
"AND (:symbol IS NULL OR e.symbol = :symbol) " +
"AND e.execStatus IN ('FILLED', 'PARTIAL_FILL') " +
"ORDER BY e.createdAt DESC")
Page<ExecutionEntity> findUserExecutions(
@Param("username") String username,
@Param("symbol") String symbol,
Pageable pageable
);
}
3. TradingScreen - React UIの実装
カスタムHookによる状態管理
複雑な取引画面の状態を効率的に管理するため、カスタムHookを活用:
interface TradingState {
orderBook: OrderBookData | null;
executions: ExecutionData[];
volume: VolumeData | null;
selectedSymbol: Symbol;
isLoading: boolean;
error: string | null;
}
const useTradingData = (symbol: Symbol) => {
const [state, setState] = useState<TradingState>({
orderBook: null,
executions: [],
volume: null,
selectedSymbol: symbol,
isLoading: true,
error: null
});
const updateOrderBook = useCallback(async () => {
try {
const data = await api.getOrderBook(symbol);
setState(prev => ({ ...prev, orderBook: data, error: null }));
} catch (error) {
setState(prev => ({
...prev,
error: `板情報取得エラー: ${error.message}`,
orderBook: mockOrderBook // フォールバック
}));
}
}, [symbol]);
// 定期更新の設定
useEffect(() => {
const intervals = [
setInterval(updateOrderBook, 1000), // 板情報: 1秒
setInterval(updateExecutions, 5000), // 約定: 5秒
setInterval(updateVolume, 10000) // 取引量: 10秒
];
return () => intervals.forEach(clearInterval);
}, [symbol]);
return { state, updateOrderBook, updateExecutions, updateVolume };
};
TypeScriptによる型安全なAPI通信
全てのAPIエンドポイントに対して厳密な型定義を実装:
// API レスポンス型定義
interface OrderBookResponse {
symbol: string;
bids: OrderLevel[];
asks: OrderLevel[];
spread: number;
spreadPercent: number;
lastUpdate: string;
}
interface OrderLevel {
price: number;
quantity: number;
}
interface ExecutionResponse {
username: string;
page: number;
size: number;
totalPages: number;
totalElements: number;
executions: ExecutionData[];
}
// API クライアントの実装
class TradingAPI {
private baseURL = '/api';
private token: string | null = null;
async getOrderBook(symbol: string): Promise<OrderBookResponse> {
const response = await this.fetch(`/market/board/${symbol}?depth=10`);
return this.handleResponse<OrderBookResponse>(response);
}
async placeOrder(order: NewOrderRequest): Promise<OrderResponse> {
const response = await this.fetch('/orders/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
return this.handleResponse<OrderResponse>(response);
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
if (response.status === 401) {
await this.refreshToken();
throw new Error('認証エラー - 再ログインが必要です');
}
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
}
レスポンシブ板情報コンポーネント
視覚的に分かりやすい板情報表示を実装:
const OrderBookDisplay: React.FC<{ data: OrderBookData }> = ({ data }) => {
const maxQuantity = useMemo(() => {
const allQuantities = [...data.bids, ...data.asks].map(level => level.quantity);
return Math.max(...allQuantities);
}, [data]);
const renderOrderLevel = (level: OrderLevel, side: 'bid' | 'ask') => {
const percentage = (level.quantity / maxQuantity) * 100;
return (
<div key={`${side}-${level.price}`} className={`order-level ${side}`}>
<div className="price">{level.price.toLocaleString()}</div>
<div className="quantity">{level.quantity.toFixed(8)}</div>
<div
className="quantity-bar"
style={{
width: `${percentage}%`,
backgroundColor: side === 'bid' ? '#4ade80' : '#f87171'
}}
/>
</div>
);
};
return (
<div className="order-book">
<div className="spread-info">
<span>スプレッド: {data.spread.toLocaleString()}円</span>
<span>({data.spreadPercent.toFixed(3)}%)</span>
</div>
<div className="asks">
{data.asks.slice().reverse().map(level =>
renderOrderLevel(level, 'ask')
)}
</div>
<div className="spread-line" />
<div className="bids">
{data.bids.map(level =>
renderOrderLevel(level, 'bid')
)}
</div>
</div>
);
};
アルゴリズム取引戦略の実装例(こちらは公開リポジトリに含まれていません)
1. シンプルアービトラージ
GMOコインとBitflyerの価格差を利用した基本的なアービトラージ戦略:
class SimpleArbitrageStrategy {
private readonly THRESHOLD = 0.001; // 0.1%の価格差で取引
private readonly TRADE_AMOUNT = 0.01; // 0.01 BTC
async checkArbitrageOpportunity() {
const gmoPrice = await this.getLatestPrice('G_BTCJPY');
const bitflyerPrice = await this.getLatestPrice('B_BTCJPY');
const priceGap = Math.abs(gmoPrice - bitflyerPrice);
const gapPercent = priceGap / Math.min(gmoPrice, bitflyerPrice);
if (gapPercent > this.THRESHOLD) {
await this.executeArbitrage(gmoPrice, bitflyerPrice);
}
}
private async executeArbitrage(gmoPrice: number, bitflyerPrice: number) {
if (gmoPrice < bitflyerPrice) {
// GMOで買い、Bitflyerで売り
await Promise.all([
this.placeOrder('G_BTCJPY', 'BUY', gmoPrice, this.TRADE_AMOUNT),
this.placeOrder('B_BTCJPY', 'SELL', bitflyerPrice, this.TRADE_AMOUNT)
]);
} else {
// Bitflyerで買い、GMOで売り
await Promise.all([
this.placeOrder('B_BTCJPY', 'BUY', bitflyerPrice, this.TRADE_AMOUNT),
this.placeOrder('G_BTCJPY', 'SELL', gmoPrice, this.TRADE_AMOUNT)
]);
}
console.log(`アービトラージ実行: 価格差 ${gapPercent.toFixed(4)}%`);
}
}
2. 現物・先物アービトラージ
現物と先物の価格差を利用した戦略:
@Component
public class SpotFutureArbitrageStrategy {
private static final BigDecimal THRESHOLD_PERCENT = new BigDecimal("0.5"); // 0.5%
private static final BigDecimal TRADE_QUANTITY = new BigDecimal("0.1");
@Scheduled(fixedDelay = 5000) // 5秒間隔でチェック
public void checkSpreadOpportunity() {
try {
BigDecimal spotPrice = getCurrentPrice("G_BTCJPY"); // GMO現物
BigDecimal futurePrice = getCurrentPrice("G_FX_BTCJPY"); // GMO先物
BigDecimal spread = futurePrice.subtract(spotPrice);
BigDecimal spreadPercent = spread.divide(spotPrice, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100"));
if (spreadPercent.abs().compareTo(THRESHOLD_PERCENT) > 0) {
executeSpreadTrade(spread.signum(), spotPrice, futurePrice);
}
} catch (Exception e) {
log.error("スプレッド取引チェックエラー", e);
}
}
private void executeSpreadTrade(int spreadDirection, BigDecimal spotPrice, BigDecimal futurePrice) {
if (spreadDirection > 0) {
// 先物が高い: 先物売り・現物買い
orderService.placeOrder(createOrder("G_FX_BTCJPY", Side.SELL, futurePrice, TRADE_QUANTITY));
orderService.placeOrder(createOrder("G_BTCJPY", Side.BUY, spotPrice, TRADE_QUANTITY));
log.info("スプレッド取引実行: 先物売り・現物買い");
} else {
// 現物が高い: 現物売り・先物買い
orderService.placeOrder(createOrder("G_BTCJPY", Side.SELL, spotPrice, TRADE_QUANTITY));
orderService.placeOrder(createOrder("G_FX_BTCJPY", Side.BUY, futurePrice, TRADE_QUANTITY));
log.info("スプレッド取引実行: 現物売り・先物買い");
}
}
}
実運用での結果と学び
こちらは、まだ実験中。後日報告します。
技術的な学び
1. WebSocket接続の課題
- 問題: Bitflyerで時々切断が発生
- 解決策: ハートビート機能と指数バックオフ再接続
- 教訓: 外部API依存では冗長性が重要
2. データ整合性の確保
- 問題: 高負荷時に約定データの不整合が発生
- 解決策: 分散ロックとトランザクション境界の明確化
- 教訓: 並行処理では悲観的ロックが安全
3. UI/UXの改善
- 問題: 取引量取得APIでCacheをつかっているが、時々重くなる。要改善。
- 解決策: React.memo と useCallback による最適化
- 教訓: パフォーマンス監視の重要性
今後の改善計画
短期的改善(1-3ヶ月)
ポジション管理と、MarketMakerからのTradeを無理やりシミュレートさせているので、バグ(仕様!?)っぽい動きがおきる。要検討。
中期的改善(3-6ヶ月)
機械学習価格予測。たぶん、Sparkを使ってやると思う。例はTensorFlowになってますが適当なものです。
// 機械学習価格予測
@Service
public class PricePredictionService {
@Autowired
private TensorFlowInferenceService tensorFlow;
public PricePrediction predictPrice(String symbol, Duration horizon) {
MarketFeatures features = featureExtractor.extractFeatures(symbol);
return tensorFlow.predict(features, horizon);
}
}
コードリポジトリ
プロジェクトのソースコードは以下で公開しています:
技術スタック詳細
Backend:
Language: Java 17
Framework: Spring Boot 3.x
Security: Spring Security + JWT
Database: H2 Database (開発用)
Build: Gradle 8.x
Testing: JUnit 5 + Mockito
Frontend:
Language: TypeScript 5.x
Framework: React 19
Build: Vite 5.x
Styling: CSS Modules
Testing: Vitest + React Testing Library
Infrastructure:
Containerization: Docker (予定)
CI/CD: GitHub Actions (予定)
Monitoring: Micrometer + Prometheus (予定)
Logging: Logback + Structured Logging
まとめ
半年間の開発を通じて、以下の技術的成果を得ることができました:
✅ 達成できたこと
- 実市場データとの統合による高精度なシミュレーション環境
- 実時間ベースの注文処理エンジンとリアルタイム約定機能
- モダンなUIによる直感的な取引体験
- 実際のアービトラージ戦略で微益を確認
🔧 改善が必要な点
- レイテンシーの最適化(目標: 100ms以下)
- スケーラビリティの向上(目標: 100同時ユーザー)
- 高度なリスク管理機能の実装
🚀 今後の展望
- 機械学習モデルの統合
- 他の暗号通貨・取引所への対応拡大
- より高度な取引戦略の実装
- 本格的な商用化の検討
暗号通貨の自動取引システムに興味がある方、フィンテック開発に関心がある方、ぜひフィードバックやコントリビュートをお待ちしています!
技術的な質問・ディスカッション
以下の点について、特にフィードバックをいただけると嬉しいです:
アーキテクチャ設計
- マイクロサービス分割の適切性
- データフローの最適化案
- スケーラビリティ改善のアイデア
パフォーマンス
- レイテンシー削減の具体的手法
- メモリ使用量最適化
- データベース設計の改善点
Discussion