💰

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