Claude 交易机器人与自定义 MCP 开发
展示如何用 Claude API 构建金融应用,以及从零开始开发 MCP 服务器的完整代码示例,落地感强。
展示如何用 Claude API 构建金融应用,以及从零开始开发 MCP 服务器的完整代码示例,落地感强。
什么是 MCP 服务器呢?
MCP 服务器就是一个服务器,可以远程托管或本地二进制文件(或本地命令),它向 LLM 公开一个函数、资源和其他信息的列表。
它通过标准化机制(见上文)公开这些,然后 Claude 等 LLM 可以选择何时调用它并请求使用工具(类似于函数调用)。
这引出我的下一个观点:我在 SnapTrade 工作(必须声明:我是以个人身份写这篇文章,而非作为 SnapTrade 的员工)— SnapTrade 允许任何有技术知识的人通过简单、统一的 API 与众多金融平台和券商集成。
那么,还有什么比用 SnapTrade 的 API 和 MCP 构建一个金融交易机器人更好的方式来探索 MCP 呢?
当我第一次研究这个问题时,我想为什么不用 Claude 来写服务器呢?同一家公司(Anthropic)同时拥有 Claude 并推动了 MCP 的开发。他们肯定会让用 Claude 编写服务器变得非常容易。对吧?结果是,我用 Gemini 的效果要好得多。我能够输入 MCP 自己的"用 LLM 构建"文档所建议的所有提示和文档,它一次就生成了可用的版本,而 Claude 则遇到了上下文限制和许多其他问题。
生成的代码仍然比我想维护的要多,如其他人所指出的那样,从头开始实现它可能相当令人沮丧。
尽管如此,这些都不是真正必要的。我通过使用 Go 的 go-mcp 框架能够更快地迭代和处理核心产品。
Go — 我相当喜欢用 Go 工作,它是一门伟大的语言,使得构建单个二进制文件变得非常容易,这些二进制文件非常容易从 Claude Desktop 指向。
go-mcp — 我评估了几个替代方案,但 go-mcp 是最直接和最容易使用的。
SnapTrade Go SDK — SnapTrade Go SDK。
markdown-table-formatter — 用于格式化 Markdown 表格的工具。在呈现位置、订单和其他金融数据等结构化数据时很有用。
cli — Go 的一个简单命令行界面库。仅用于在开发过程中更快地迭代时构建辅助伴随 CLI 应用,在 MCP 服务器本身中既不必需也不使用。
SnapTrade 客户端 ID 和密钥 — 通过此链接注册以获取免费测试密钥,该密钥可让你获得有限数量的免费连接,以及纸面交易的访问权限。
有时我发现在开始处理项目之前查看目录结构的一般概述会很有帮助。以下大致是我们将构建的内容。我们有 bin 保存构建的二进制文件,cmd 保存我们想要构建的"命令"(现在只有 1 个),internal 保存多个对我们服务器内部的包。
最重要的子目录是 tools,它保存我们想通过 MCP 服务器向 Claude 提供的每个工具的代码。
.
├── bin
│ ├── cli
│ ├── .env
│ └── .env.example
├── cmd
│ ├── cli
│ │ └── main.go
│ └── manage
│ └── main.go
├── .gitignore
├── go.mod
├── go.sum
├── internal
│ ├── snaptradeclient
│ │ └── snaptrade.go
│ └── tools
│ ├── connect
│ │ └── connect.go
│ ├── help
│ │ └── help.go
│ ├── orders
│ │ └── orders.go
│ ├── portfolio
│ │ └── portfolio.go
│ └── trades
│ └── trades.go
└── Makefile
让我们从第一个工具开始。这将定义我们将为每个工具重用的模式或结构。这个文件应该位于 internal/tools/help/help.go。
package help
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
// 这只是使用 MCP 框架定义一个新工具。拥有
// 清晰且有帮助的工具名称和描述很重要,因为这是 Claude
// 用来确定何时以及是否运行你的工具的依据。
var Tool = mcp.NewTool("get_started_with_brokerage_connection",
mcp.WithDescription("Provides information on how to connect your brokerage account and lists the supported brokerages."),
)
// 每个工具都有一个处理程序,返回一个与 `go-mcp`
// 工具处理程序函数签名匹配的函数。我们使用它来轻松传递
// 一个 `snaptradeclient.SnapTradeClient` 的实例,它包含所有
// 通过 SnapTrade Go SDK 与 SnapTrade 交互的代码
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// 注意:这个特定的处理程序目前不会*使用*客户端,
// 但我们调整签名以保持一致性和未来使用。
return mcp.NewToolResultText("To get started with investing and portfolio management, please let us know which brokerage you have an account with. We can help you connect your account to any of the following brokerages: Trading212, Vanguard, Schwab, Alpaca, Alpaca Paper, Tradier, Robinhood, Fidelity, ETrade."), nil
}
}
很简单,对吧?没有比这更多的了。当你询问有关券商的信息或查看投资组合价值时,Claude 现在会有用地回复我们这里收集的信息。你可以在 GitHub 上看到 snaptradeclient.SnapTradeClient 是什么以及它做什么,我试图留下大量注释来帮助你浏览代码库。
当然,接下来是实际连接到你选择的券商。我们再次通过 go-mcp 向 Claude 公开一个工具,以及一个接受的字符串列表作为一种枚举。然后我们将回复一个指向 SnapTrade 连接门户的链接,该门户允许你轻松安全地连接到你选择的券商。由于其优秀的纸面交易能力和简易的 OAuth 连接流程,我建议在这个演示中使用 Alpaca Paper。
package connect
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
var Tool = mcp.NewTool("connect_brokerage",
mcp.WithDescription("Connect your brokerage account to see your portfolio and trades for that account."),
mcp.WithString("brokerage",
mcp.Required(),
mcp.Description("The brokerage to connect to"),
mcp.Enum("Trading212", "Vanguard", "Schwab", "Alpaca", "Alpaca Paper", "Tradier", "Robinhood", "Fidelity", "ETrade"),
),
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
brokerage, ok := request.Params.Arguments["brokerage"].(string)
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
// SnapTrade API 使用这些 slug 来预先选择要连接的券商,所以我们需要
// 将用户的选择映射到正确的 slug。
nameToSlug := map[string]string{
"Trading212": "TRADING212",
"Vanguard": "VANGUARD",
"Schwab": "SCHWAB",
"Alpaca": "ALPACA",
"Alpaca Paper": "ALPACA-PAPER",
"Tradier": "TRADIER",
"Robinhood": "ROBINHOOD",
"Fidelity": "FIDELITY",
"ETrade": "ETRADE",
}
slug, ok := nameToSlug[brokerage]
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
// 这会调用我们自定义的 SnapTrade 客户端,并获取一个要展示给
// 用户的 URL。然后用户需要点击该链接来完成连接。
redirectURI, err := cl.LoginUserAndGetRedirectURI(slug)
if err != nil {
return mcp.NewToolResultError("Failed to generate connection link for SnapTrade"), nil
}
return mcp.NewToolResultText(fmt.Sprintf("To connect to %s, you must present this link to the user. Please note that it expires, so even if you've shown it before, you need to show this new one:\n %s", brokerage, redirectURI)), nil
}
}
这里需要用户进行操作:你需要点击链接并按照说明连接你的 Alpaca Paper 账户。连接完成后,返回 Claude 继续对话。
乍看之下,这段代码可能有些复杂,但实际上它只是在做以下几件事:
package portfolio
import (
"context"
"fmt"
"github.com/fbiville/markdown-table-formatter/pkg/markdown"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
st "github.com/passiv/snaptrade-sdks/sdks/go"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
var Tool = mcp.NewTool("portfolio",
mcp.WithDescription("Check your portfolio and brokerage accounts for their positions and values.")
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Use the client method
response, err := cl.ListUserAccounts()
if err != nil {
fmt.Println("Error retrieving accounts:", err)
return mcp.NewToolResultError("Error retrieving accounts"), nil
}
if len(response) == 0 {
fmt.Println("No brokerage accounts connected")
return mcp.NewToolResultError("No brokerage accounts connected"), nil
}
reply := "Your connected brokerage accounts are:\n"
// Group the accounts by institution
accounts := make(map[string][]st.Account)
for _, account := range response {
institution := account.GetInstitutionName()
if _, ok := accounts[institution]; !ok {
accounts[institution] = []st.Account{}
}
accounts[institution] = append(accounts[institution], account)
}
// Find all accounts
for institution, accountsList := range accounts {
reply += fmt.Sprintf("%s:\n", institution)
for _, account := range accountsList {
marketValueDescription := ""
if account.Balance.GetTotal().Amount != nil && account.Balance.GetTotal().Currency != nil {
marketValue := *account.Balance.GetTotal().Amount
marketValueCurrency := *account.Balance.GetTotal().Currency
marketValueDescription = fmt.Sprintf("(value including cash: %.2f %s)", marketValue, marketValueCurrency)
}
reply += fmt.Sprintf("%s %s\n\nPlease show this markdown formatted table of all the positions under this account\n\n", *account.Name.Get(), marketValueDescription)
// Now get all the positions for this account using the client method
positions, err := cl.GetUserAccountPositions(account.Id)
if err != nil {
fmt.Println("Error retrieving positions for account", account.Name, ":", err)
continue // Or add an error message to the reply
}
if len(positions) == 0 {
reply += fmt.Sprintf(" - No positions found for account %s\n", *account.Name.Get())
continue
} else {
tableData := make([][]string, len(positions))
for i, position := range positions {
tableData[i] = []string{
position.Symbol.Symbol.Symbol,
fmt.Sprintf("%.4f", *position.Units.Get()),
fmt.Sprintf("%.2f %s", *position.Price.Get()**position.Units.Get(), *position.Symbol.Symbol.Currency.Code),
}
}
basicTable, err := markdown.NewTableFormatterBuilder().
Build("Instrument", "Units", "Value").
Format(tableData)
if err != nil {
fmt.Println("Error creating table:", err)
continue // Or add an error message to the reply
}
// Add the table to the reply
reply += fmt.Sprintf("%s\n", basicTable)
}
}
}
fmt.Println(reply)
return mcp.NewToolResultText(reply), nil
}
}
现在到了最令人兴奋的部分。我有意让 MCP 服务器中的交易选项列表和配置保持简单,不过你可以查阅 SnapTrade 中有关下单的相关文档,轻松地扩展它。
除了一些基本验证外,我们会通过 SDK 将获取到的所有数据直接发送给 SnapTrade,由它负责处理。
package trades
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient" // Use snaptradeclient instead of snaptrade
)
var Tool = mcp.NewTool("place_order",
mcp.WithDescription("Place an order with your brokerage account"),
mcp.WithString("brokerage",
mcp.Required(),
mcp.Description("The brokerage to place an order with"),
mcp.Enum("Trading212", "Vanguard", "Schwab", "Alpaca", "Alpaca Paper", "Tradier", "Robinhood", "Fidelity", "ETrade"),
),
mcp.WithString("action",
mcp.Required(),
mcp.Description("The action to perform (BUY/SELL)"),
mcp.Enum("BUY", "SELL"),
),
mcp.WithString("ticker",
mcp.Required(),
mcp.Description("The ticker symbol of the stock"),
),
mcp.WithNumber("quantity",
mcp.Required(),
mcp.Description("The quantity of shares to buy/sell"),
),
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
brokerage, ok := request.Params.Arguments["brokerage"].(string)
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
action, ok := request.Params.Arguments["action"].(string)
if !ok {
return mcp.NewToolResultError("Invalid action"), nil
}
ticker, ok := request.Params.Arguments["ticker"].(string)
if !ok {
return mcp.NewToolResultError("Invalid ticker"), nil
}
quantity, ok := request.Params.Arguments["quantity"].(float64)
if !ok {
return mcp.NewToolResultError("Invalid quantity"), nil
}
quantityFloat := float32(quantity)
// Use the client method
response, err := cl.ListUserAccounts()
if err != nil {
fmt.Println("Error retrieving accounts:", err)
return mcp.NewToolResultError("Error retrieving accounts"), nil // Return error to MCP
}
if len(response) == 0 {
return mcp.NewToolResultError("No brokerage accounts connected"), nil
}
accountId := ""
for _, account := range response {
if account.GetInstitutionName() == brokerage {
accountId = account.Id
break // Found the account
}
}
if accountId == "" {
return mcp.NewToolResultError(fmt.Sprintf("No matching account found for brokerage: %s", brokerage)), nil
}
// Use the client method
orderRecord, err := cl.PlaceForceOrder(accountId, action, ticker, quantityFloat)
if err != nil {
fmt.Println("Error placing order:", err)
return mcp.NewToolResultError(fmt.Sprintf("Error placing order: %s", err.Error())), nil // Return error to MCP
}
return mcp.NewToolResultText(fmt.Sprintf("Order placed successfully: %s. You can monitor the status of your order by asking me to show your recent orders.", *orderRecord.BrokerageOrderId)), nil
}
}
正如本文开头所展示的那样,实际效果可能就是如此。
通过 Claude 这样的 LLM 进行交易或执行任何高影响操作时一定要谨慎。虽然在大多数尝试中它都运行良好,但测试期间有几次交易下单失败后,它竟然自行反复尝试下单,而且每次交易数量都比上一次更多——例如,它没有只买 1 股,而是先尝试买 2 股,接着又尝试买 3 股。因此,你最终可能会下达并非自己本意的订单。
同样,由于 LLM 具有不可预测性,我在尝试让它显示连接券商账户的链接时,始终无法达到 100% 的成功率——它一直声称自己已经显示过该链接,而不是直接展示新链接。因此,如果你决定将 LLM 接入此类工具,显然还有一些 bug 和棘手问题需要解决。
这个 MCP 服务器示例还提供了一个用于查看近期订单的工具,但我不打算在这篇博客文章中用相关代码让你感到厌烦——你可以前往 snaptrade-mcp GitHub 仓库自行查看全部内容。
MCP 是一种强大的工具,可用于构建各式各样的应用程序。它仍处于早期阶段,但有望彻底改变我们构建应用程序和提供服务的方式——前提是我们清楚它的局限性。