🐀

(GUI)通貨取引アプリ作ってみた

2024/11/29に公開

前回、CLIアプリで作ったものをGUIにして直感的に取引できるようにしました。
前回作ったものはこちらです。(cli)通貨取引アプリ作ってみた

概要

通貨取引のようなアプリ。
売買のオーダーが可能。
方法は先入れ先出し。
前回は、オーダーが約定した場合、最新の1件のオーダーと部分約定していました。そのため、約定できるはずのオーダーが残ってしまいます。
今回は、オーダーが通る場合、複数のオーダーを使って完全約定できるようになりました。

はじめに

通貨取引アプリを直感的に操作できるようダッシュボード上に取引できるアプリを作りました。
https://github.com/gen-gen33/trading_app_gui

動作環境

  • windows 11
  • Go 1.23.3
  • Gin v1.10.0
  • CockroachDB v24.1.6
    Build Time: 2024/10/14 17:21:12

技術スタック

動作イメージ

まずはログインします。
ない場合はサインインからアカウントを作成し、ログインします。
ログイン画面

ログインできたらダッシュボードに遷移します。
ここでは、

  • トレード
  • オーダーブック
  • トレード履歴(すべて)
    を見ることができます。
    もちろん、ここで注文を送信できます。
    ダッシュボード

さらにダッシュボードをスクロールすると、オーダーブック、トレード履歴の確認ができます。
本当は、3列にして、一目で全部見渡したい、と思ったのですが、時間がかかりそうだったため今回は見送りました。

オーダーブック、トレード履歴

主な機能

ログイン機能

ログインできるようにしました。
ログイン時、クッキーにユーザーネームを付与するようにしました。

c.SetCookie("trading_session", loginData.Username, 3600, "/", "localhost", false, true)

もしログインしていない場合には、ダッシュボードに行ってもログイン画面にリダイレクトされます。クッキーがない場合をログインしていない、とみなしています。一般的な方法はなんだろう。

// クッキーからユーザー名を取得
username, err := c.Cookie("trading_session")
    if err != nil {
    	// クッキーがない場合はログインページにリダイレクト
    	c.Redirect(http.StatusSeeOther, "/login")
    	return
    }
	c.HTML(http.StatusOK, "dashboard.html", gin.H{"username": username})
})

トレード機能

オーダーを作って、データベースに登録し、その後、データベース内のオーダーとのマッチングを試みます。もしあれば、約定、なければそのまま注文終了となります。

オーダー側が部分約定した場合、その分だけもとのオーダーの数量を減らします。

データベース側(過去のオーダー)が部分約定した場合は、データベースの数量を減らします。オーダー側は完全約定のため、そのままステータスを約定にします。

これだけだと、過去のオーダー1件分だけ完全約定してしまうと、完全約定とみなされてしまい、残りの新規オーダーが未約定として処理されてしまいます。そこで、繰り返し文を使って残ったオーダーをもう一度約定させにかかります。

過去の他のオーダーで約定できそうなら古い順から片っ端から約定していきます。完全約定、もしくは、過去のオーダーで処理できるオーダーがなくなれば、取引終了となります。

トレードの部分を個別に貼ると筆者自身混乱してしまったので関数をそのまま載せています。
ちょっと長いです。

トレードのコード(ちょっと長いです)
func MatchOrder(db *sql.DB, newOrder models.Order) (bool, string, error) {
	var totalMatchedAmount float64
	var allMatchedMessages []string

	for {
		tx, err := db.Begin() // トランザクションを開始
		if err != nil {
			return false, "", err
		}

		// マッチング注文を検索
		matchQuery := `
			SELECT id, user_id, type, amount, price
			FROM orders
			WHERE status = 'open'
			AND type = CASE WHEN $1 = 'buy' THEN 'sell' ELSE 'buy' END
			AND (
				(type = 'buy' AND price >= $2)
				OR
				(type = 'sell' AND price <= $2)
			)
			ORDER BY created_at ASC
			LIMIT 1;
		`
		var matchedOrder models.Order
		err = tx.QueryRow(matchQuery, newOrder.Type, newOrder.Price).Scan(
			&matchedOrder.ID,
			&matchedOrder.User,
			&matchedOrder.Type,
			&matchedOrder.Amount,
			&matchedOrder.Price,
		)
		if err == sql.ErrNoRows {
			// マッチング注文なしで終了
			tx.Rollback()
			if totalMatchedAmount > 0 {
				return true, fmt.Sprintf(
					"Order partially matched for a total of %.2f units. Remaining order added to the book.",
					newOrder.Amount,
				), nil
			}
			return false, "Order added to the open book. No match found.", nil
		} else if err != nil {
			tx.Rollback()
			return false, "", err
		}

		// 部分約定を計算
		tradeAmount := newOrder.Amount
		if newOrder.Amount > matchedOrder.Amount {
			tradeAmount = matchedOrder.Amount
		}

		// トレード履歴を保存
		tradeInsertQuery := `
			INSERT INTO trades (buy_order_id, sell_order_id, amount, price, created_at)
			VALUES ($1, $2, $3, $4, now())
		`
		if newOrder.Type == "buy" {
			_, err = tx.Exec(tradeInsertQuery, newOrder.ID, matchedOrder.ID, tradeAmount, matchedOrder.Price)
		} else {
			_, err = tx.Exec(tradeInsertQuery, matchedOrder.ID, newOrder.ID, tradeAmount, matchedOrder.Price)
		}
		if err != nil {
			tx.Rollback()
			return false, "", err
		}

		// 更新処理(注文量とステータス)
		if newOrder.Amount > matchedOrder.Amount {
			// 新規注文は部分約定
			updateQuery := "UPDATE orders SET amount = amount - $1 WHERE id = $2"
			_, err = tx.Exec(updateQuery, tradeAmount, newOrder.ID)
			if err != nil {
				tx.Rollback()
				return false, "", err
			}

			// 既存注文は完全約定
			_, err = tx.Exec("UPDATE orders SET status = 'matched' WHERE id = $1", matchedOrder.ID)
			if err != nil {
				tx.Rollback()
				return false, "", err
			}

			// 新規注文の残量を更新
			newOrder.Amount -= tradeAmount
		} else if newOrder.Amount < matchedOrder.Amount {
			// 既存注文は部分約定
			updateQuery := "UPDATE orders SET amount = amount - $1 WHERE id = $2"
			_, err = tx.Exec(updateQuery, tradeAmount, matchedOrder.ID)
			if err != nil {
				tx.Rollback()
				return false, "", err
			}

			// 新規注文は完全約定
			_, err = tx.Exec("UPDATE orders SET status = 'matched' WHERE id = $1", newOrder.ID)
			if err != nil {
				tx.Rollback()
				return false, "", err
			}

			// 完全約定で終了
			tx.Commit()
			allMatchedMessages = append(allMatchedMessages, fmt.Sprintf(
				"Trade executed: %s %s %.2f units at %.2f with %s.",
				newOrder.User, newOrder.Type, tradeAmount, matchedOrder.Price, matchedOrder.User,
			))
			return true, fmt.Sprintf("Order fully matched. %s", allMatchedMessages), nil
		} else {
			// 両方完全約定
			_, err = tx.Exec("UPDATE orders SET status = 'matched' WHERE id = $1 OR id = $2", newOrder.ID, matchedOrder.ID)
			if err != nil {
				tx.Rollback()
				return false, "", err
			}

			// 完全約定で終了
			tx.Commit()
			allMatchedMessages = append(allMatchedMessages, fmt.Sprintf(
				"Trade executed: %s %s %.2f units at %.2f with %s.",
				newOrder.User, newOrder.Type, tradeAmount, matchedOrder.Price, matchedOrder.User,
			))
			return true, fmt.Sprintf("Order fully matched. %s", allMatchedMessages), nil
		}

		// 累計マッチングデータを更新
		totalMatchedAmount += tradeAmount
		allMatchedMessages = append(allMatchedMessages, fmt.Sprintf(
			"Trade executed: %s %s %.2f units at %.2f with %s.",
			newOrder.User, newOrder.Type, tradeAmount, matchedOrder.Price, matchedOrder.User,
		))

		// トランザクションをコミット
		err = tx.Commit()
		if err != nil {
			return false, "", err
		}
	}
}

おわりに

GUIでの操作ができるだけで、とてもそれらしくなり満足感があります。
これで直感的に取引できるアプリにはなったと思います。
(ユーザーはお金を持っていなくても取引できてしまうんですけどね)

Discussion