schwab_mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.copilotignore +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/Rakefile +12 -0
- data/exe/schwab_mcp +19 -0
- data/exe/schwab_token_refresh +38 -0
- data/exe/schwab_token_reset +49 -0
- data/lib/schwab_mcp/loggable.rb +31 -0
- data/lib/schwab_mcp/logger.rb +62 -0
- data/lib/schwab_mcp/option_chain_filter.rb +213 -0
- data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
- data/lib/schwab_mcp/orders/order_factory.rb +40 -0
- data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
- data/lib/schwab_mcp/redactor.rb +210 -0
- data/lib/schwab_mcp/resources/.keep +0 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
- data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
- data/lib/schwab_mcp/tools/help_tool.rb +406 -0
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
- data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
- data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
- data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
- data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
- data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
- data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
- data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
- data/lib/schwab_mcp/version.rb +5 -0
- data/lib/schwab_mcp.rb +107 -0
- data/sig/schwab_mcp.rbs +4 -0
- data/start_mcp_server.sh +4 -0
- metadata +115 -0
@@ -0,0 +1,226 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
|
6
|
+
module SchwabMCP
|
7
|
+
module Tools
|
8
|
+
class CancelOrderTool < MCP::Tool
|
9
|
+
extend Loggable
|
10
|
+
description "Cancel a specific order by order ID using account name mapping"
|
11
|
+
|
12
|
+
input_schema(
|
13
|
+
properties: {
|
14
|
+
order_id: {
|
15
|
+
type: "string",
|
16
|
+
description: "The order ID to cancel",
|
17
|
+
pattern: "^\\d+$"
|
18
|
+
},
|
19
|
+
account_name: {
|
20
|
+
type: "string",
|
21
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
22
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
23
|
+
}
|
24
|
+
},
|
25
|
+
required: ["order_id", "account_name"]
|
26
|
+
)
|
27
|
+
|
28
|
+
annotations(
|
29
|
+
title: "Cancel Order",
|
30
|
+
read_only_hint: false,
|
31
|
+
destructive_hint: true,
|
32
|
+
idempotent_hint: false
|
33
|
+
)
|
34
|
+
|
35
|
+
def self.call(order_id:, account_name:, server_context:)
|
36
|
+
log_info("Attempting to cancel order ID: #{order_id} in account: #{account_name}")
|
37
|
+
|
38
|
+
unless account_name.end_with?('_ACCOUNT')
|
39
|
+
log_error("Invalid account name format: #{account_name}")
|
40
|
+
return MCP::Tool::Response.new([{
|
41
|
+
type: "text",
|
42
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
43
|
+
}])
|
44
|
+
end
|
45
|
+
|
46
|
+
unless order_id.match?(/^\d+$/)
|
47
|
+
log_error("Invalid order ID format: #{order_id}")
|
48
|
+
return MCP::Tool::Response.new([{
|
49
|
+
type: "text",
|
50
|
+
text: "**Error**: Order ID must be numeric. Example: '123456789'"
|
51
|
+
}])
|
52
|
+
end
|
53
|
+
|
54
|
+
begin
|
55
|
+
client = SchwabRb::Auth.init_client_easy(
|
56
|
+
ENV['SCHWAB_API_KEY'],
|
57
|
+
ENV['SCHWAB_APP_SECRET'],
|
58
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
59
|
+
ENV['TOKEN_PATH']
|
60
|
+
)
|
61
|
+
|
62
|
+
unless client
|
63
|
+
log_error("Failed to initialize Schwab client")
|
64
|
+
return MCP::Tool::Response.new([{
|
65
|
+
type: "text",
|
66
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
67
|
+
}])
|
68
|
+
end
|
69
|
+
|
70
|
+
account_id = ENV[account_name]
|
71
|
+
unless account_id
|
72
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
73
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
74
|
+
return MCP::Tool::Response.new([{
|
75
|
+
type: "text",
|
76
|
+
text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
|
77
|
+
}])
|
78
|
+
end
|
79
|
+
|
80
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
81
|
+
log_debug("Fetching account numbers mapping")
|
82
|
+
|
83
|
+
account_numbers_response = client.get_account_numbers
|
84
|
+
|
85
|
+
unless account_numbers_response&.body
|
86
|
+
log_error("Failed to retrieve account numbers")
|
87
|
+
return MCP::Tool::Response.new([{
|
88
|
+
type: "text",
|
89
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
90
|
+
}])
|
91
|
+
end
|
92
|
+
|
93
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
94
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
95
|
+
|
96
|
+
account_hash = nil
|
97
|
+
account_mappings.each do |mapping|
|
98
|
+
if mapping[:accountNumber] == account_id
|
99
|
+
account_hash = mapping[:hashValue]
|
100
|
+
break
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
unless account_hash
|
105
|
+
log_error("Account ID not found in available accounts")
|
106
|
+
return MCP::Tool::Response.new([{
|
107
|
+
type: "text",
|
108
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
109
|
+
}])
|
110
|
+
end
|
111
|
+
|
112
|
+
log_debug("Found account hash for account ID: #{account_name}")
|
113
|
+
log_debug("Verifying order exists before attempting cancellation")
|
114
|
+
|
115
|
+
order_response = client.get_order(order_id, account_hash)
|
116
|
+
|
117
|
+
unless order_response&.body
|
118
|
+
log_warn("Order not found or empty response for order ID: #{order_id}")
|
119
|
+
return MCP::Tool::Response.new([{
|
120
|
+
type: "text",
|
121
|
+
text: "**Warning**: Order ID #{order_id} not found or empty response. Order may not exist in the specified account."
|
122
|
+
}])
|
123
|
+
end
|
124
|
+
|
125
|
+
order_data = JSON.parse(order_response.body)
|
126
|
+
order_status = order_data['status']
|
127
|
+
cancelable = order_data['cancelable']
|
128
|
+
|
129
|
+
log_debug("Order found - Status: #{order_status}, Cancelable: #{cancelable}")
|
130
|
+
if cancelable == false
|
131
|
+
log_warn("Order #{order_id} is not cancelable (Status: #{order_status})")
|
132
|
+
return MCP::Tool::Response.new([{
|
133
|
+
type: "text",
|
134
|
+
text: "**Warning**: Order ID #{order_id} cannot be cancelled.\n\n**Current Status**: #{order_status}\n**Cancelable**: #{cancelable}\n\nOrders that are already filled, cancelled, or expired cannot be cancelled."
|
135
|
+
}])
|
136
|
+
end
|
137
|
+
|
138
|
+
log_info("Attempting to cancel order ID: #{order_id} (Status: #{order_status})")
|
139
|
+
cancel_response = client.cancel_order(order_id, account_hash)
|
140
|
+
|
141
|
+
if cancel_response.respond_to?(:status) && cancel_response.status == 200
|
142
|
+
log_info("Successfully cancelled order ID: #{order_id}")
|
143
|
+
formatted_response = format_cancellation_success(order_id, account_name, order_data)
|
144
|
+
elsif cancel_response.respond_to?(:status) && cancel_response.status == 404
|
145
|
+
log_warn("Order not found during cancellation: #{order_id}")
|
146
|
+
return MCP::Tool::Response.new([{
|
147
|
+
type: "text",
|
148
|
+
text: "**Warning**: Order ID #{order_id} not found during cancellation. It may have already been cancelled or filled."
|
149
|
+
}])
|
150
|
+
else
|
151
|
+
log_info("Order cancellation request submitted for order ID: #{order_id}")
|
152
|
+
formatted_response = format_cancellation_success(order_id, account_name, order_data)
|
153
|
+
end
|
154
|
+
|
155
|
+
MCP::Tool::Response.new([{
|
156
|
+
type: "text",
|
157
|
+
text: formatted_response
|
158
|
+
}])
|
159
|
+
|
160
|
+
rescue JSON::ParserError => e
|
161
|
+
log_error("JSON parsing error: #{e.message}")
|
162
|
+
MCP::Tool::Response.new([{
|
163
|
+
type: "text",
|
164
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
165
|
+
}])
|
166
|
+
rescue => e
|
167
|
+
log_error("Error cancelling order ID #{order_id}: #{e.message}")
|
168
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
169
|
+
|
170
|
+
error_message = if e.message.include?("401") || e.message.include?("403")
|
171
|
+
"**Error**: Authorization failed. Check your API credentials and permissions for order cancellation."
|
172
|
+
elsif e.message.include?("400")
|
173
|
+
"**Error**: Bad request. Order ID #{order_id} may be invalid or cannot be cancelled at this time."
|
174
|
+
elsif e.message.include?("404")
|
175
|
+
"**Error**: Order ID #{order_id} not found in the specified account."
|
176
|
+
else
|
177
|
+
"**Error** cancelling order ID #{order_id}: #{e.message}"
|
178
|
+
end
|
179
|
+
|
180
|
+
MCP::Tool::Response.new([{
|
181
|
+
type: "text",
|
182
|
+
text: "#{error_message}\n\n#{e.backtrace.first(3).join('\n')}"
|
183
|
+
}])
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def self.format_cancellation_success(order_id, account_name, order_data)
|
190
|
+
friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
191
|
+
|
192
|
+
formatted = "**✅ Order Cancellation Successful**\n\n"
|
193
|
+
formatted += "**Order ID**: #{order_id}\n"
|
194
|
+
formatted += "**Account**: #{friendly_name} (#{account_name})\n\n"
|
195
|
+
formatted += "**Order Details:**\n"
|
196
|
+
formatted += "- Original Status: #{order_data['status']}\n" if order_data['status']
|
197
|
+
formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
|
198
|
+
formatted += "- Session: #{order_data['session']}\n" if order_data['session']
|
199
|
+
formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
|
200
|
+
formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
|
201
|
+
formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
|
202
|
+
|
203
|
+
if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
|
204
|
+
formatted += "\n**Instruments:**\n"
|
205
|
+
order_data['orderLegCollection'].each do |leg|
|
206
|
+
if leg['instrument']
|
207
|
+
symbol = leg['instrument']['symbol']
|
208
|
+
instruction = leg['instruction']
|
209
|
+
quantity = leg['quantity']
|
210
|
+
formatted += "- #{symbol}: #{instruction} #{quantity}\n"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
formatted += "\n**Note**: The order cancellation has been submitted. Please verify the cancellation by checking your order status or using the `get_order_tool` or `list_account_orders_tool`."
|
216
|
+
|
217
|
+
formatted
|
218
|
+
end
|
219
|
+
|
220
|
+
def self.format_currency(amount)
|
221
|
+
return "0.00" if amount.nil?
|
222
|
+
"%.2f" % amount.to_f
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require "date"
|
5
|
+
require_relative "../loggable"
|
6
|
+
|
7
|
+
module SchwabMCP
|
8
|
+
module Tools
|
9
|
+
class GetMarketHoursTool < MCP::Tool
|
10
|
+
extend Loggable
|
11
|
+
description "Get market hours for specified markets using Schwab API"
|
12
|
+
|
13
|
+
input_schema(
|
14
|
+
properties: {
|
15
|
+
markets: {
|
16
|
+
type: "array",
|
17
|
+
description: "Markets for which to return trading hours",
|
18
|
+
items: {
|
19
|
+
type: "string",
|
20
|
+
enum: ["equity", "option", "bond", "future", "forex"]
|
21
|
+
},
|
22
|
+
minItems: 1
|
23
|
+
},
|
24
|
+
date: {
|
25
|
+
type: "string",
|
26
|
+
description: "Date for market hours in YYYY-MM-DD format (optional, defaults to today). Accepts values up to one year from today.",
|
27
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
|
28
|
+
}
|
29
|
+
},
|
30
|
+
required: ["markets"]
|
31
|
+
)
|
32
|
+
|
33
|
+
annotations(
|
34
|
+
title: "Get Market Hours",
|
35
|
+
read_only_hint: true,
|
36
|
+
destructive_hint: false,
|
37
|
+
idempotent_hint: true
|
38
|
+
)
|
39
|
+
|
40
|
+
def self.call(markets:, date: nil, server_context:)
|
41
|
+
log_info("Getting market hours for markets: #{markets.join(', ')}")
|
42
|
+
log_debug("Date parameter: #{date || 'today'}")
|
43
|
+
|
44
|
+
begin
|
45
|
+
client = SchwabRb::Auth.init_client_easy(
|
46
|
+
ENV['SCHWAB_API_KEY'],
|
47
|
+
ENV['SCHWAB_APP_SECRET'],
|
48
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
49
|
+
ENV['TOKEN_PATH']
|
50
|
+
)
|
51
|
+
|
52
|
+
unless client
|
53
|
+
log_error("Failed to initialize Schwab client")
|
54
|
+
return MCP::Tool::Response.new([{
|
55
|
+
type: "text",
|
56
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
57
|
+
}])
|
58
|
+
end
|
59
|
+
|
60
|
+
# Parse date if provided
|
61
|
+
parsed_date = nil
|
62
|
+
if date
|
63
|
+
begin
|
64
|
+
parsed_date = Date.parse(date)
|
65
|
+
log_debug("Parsed date: #{parsed_date}")
|
66
|
+
rescue ArgumentError => e
|
67
|
+
log_error("Invalid date format: #{date}")
|
68
|
+
return MCP::Tool::Response.new([{
|
69
|
+
type: "text",
|
70
|
+
text: "**Error**: Invalid date format '#{date}'. Please use YYYY-MM-DD format."
|
71
|
+
}])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
log_debug("Making API request for markets: #{markets.join(', ')}")
|
76
|
+
response = client.get_market_hours(markets, date: parsed_date)
|
77
|
+
|
78
|
+
if response&.body
|
79
|
+
log_info("Successfully retrieved market hours for #{markets.join(', ')}")
|
80
|
+
date_info = date ? " for #{date}" : " for today"
|
81
|
+
MCP::Tool::Response.new([{
|
82
|
+
type: "text",
|
83
|
+
text: "**Market Hours#{date_info}:**\n\n```json\n#{response.body}\n```"
|
84
|
+
}])
|
85
|
+
else
|
86
|
+
log_warn("Empty response from Schwab API for markets: #{markets.join(', ')}")
|
87
|
+
MCP::Tool::Response.new([{
|
88
|
+
type: "text",
|
89
|
+
text: "**No Data**: Empty response from Schwab API for markets: #{markets.join(', ')}"
|
90
|
+
}])
|
91
|
+
end
|
92
|
+
|
93
|
+
rescue => e
|
94
|
+
log_error("Error retrieving market hours for #{markets.join(', ')}: #{e.message}")
|
95
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
96
|
+
MCP::Tool::Response.new([{
|
97
|
+
type: "text",
|
98
|
+
text: "**Error** retrieving market hours for #{markets.join(', ')}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
99
|
+
}])
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
require_relative "../redactor"
|
6
|
+
|
7
|
+
module SchwabMCP
|
8
|
+
module Tools
|
9
|
+
class GetOrderTool < MCP::Tool
|
10
|
+
extend Loggable
|
11
|
+
description "Get details for a specific order by order ID using account name mapping"
|
12
|
+
|
13
|
+
input_schema(
|
14
|
+
properties: {
|
15
|
+
order_id: {
|
16
|
+
type: "string",
|
17
|
+
description: "The order ID to retrieve details for",
|
18
|
+
pattern: "^\\d+$"
|
19
|
+
},
|
20
|
+
account_name: {
|
21
|
+
type: "string",
|
22
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
23
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
24
|
+
}
|
25
|
+
},
|
26
|
+
required: ["order_id", "account_name"]
|
27
|
+
)
|
28
|
+
|
29
|
+
annotations(
|
30
|
+
title: "Get Order Details",
|
31
|
+
read_only_hint: true,
|
32
|
+
destructive_hint: false,
|
33
|
+
idempotent_hint: true
|
34
|
+
)
|
35
|
+
|
36
|
+
def self.call(order_id:, account_name:, server_context:)
|
37
|
+
log_info("Getting order details for order ID: #{order_id} in account: #{account_name}")
|
38
|
+
|
39
|
+
unless account_name.end_with?('_ACCOUNT')
|
40
|
+
log_error("Invalid account name format: #{account_name}")
|
41
|
+
return MCP::Tool::Response.new([{
|
42
|
+
type: "text",
|
43
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
44
|
+
}])
|
45
|
+
end
|
46
|
+
|
47
|
+
unless order_id.match?(/^\d+$/)
|
48
|
+
log_error("Invalid order ID format: #{order_id}")
|
49
|
+
return MCP::Tool::Response.new([{
|
50
|
+
type: "text",
|
51
|
+
text: "**Error**: Order ID must be numeric. Example: '123456789'"
|
52
|
+
}])
|
53
|
+
end
|
54
|
+
|
55
|
+
begin
|
56
|
+
client = SchwabRb::Auth.init_client_easy(
|
57
|
+
ENV['SCHWAB_API_KEY'],
|
58
|
+
ENV['SCHWAB_APP_SECRET'],
|
59
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
60
|
+
ENV['TOKEN_PATH']
|
61
|
+
)
|
62
|
+
|
63
|
+
unless client
|
64
|
+
log_error("Failed to initialize Schwab client")
|
65
|
+
return MCP::Tool::Response.new([{
|
66
|
+
type: "text",
|
67
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
68
|
+
}])
|
69
|
+
end
|
70
|
+
|
71
|
+
account_id = ENV[account_name]
|
72
|
+
unless account_id
|
73
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
74
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
75
|
+
return MCP::Tool::Response.new([{
|
76
|
+
type: "text",
|
77
|
+
text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
|
78
|
+
}])
|
79
|
+
end
|
80
|
+
|
81
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
82
|
+
log_debug("Fetching account numbers mapping")
|
83
|
+
|
84
|
+
account_numbers_response = client.get_account_numbers
|
85
|
+
|
86
|
+
unless account_numbers_response&.body
|
87
|
+
log_error("Failed to retrieve account numbers")
|
88
|
+
return MCP::Tool::Response.new([{
|
89
|
+
type: "text",
|
90
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
91
|
+
}])
|
92
|
+
end
|
93
|
+
|
94
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
95
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
96
|
+
|
97
|
+
account_hash = nil
|
98
|
+
account_mappings.each do |mapping|
|
99
|
+
if mapping[:accountNumber] == account_id
|
100
|
+
account_hash = mapping[:hashValue]
|
101
|
+
break
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
unless account_hash
|
106
|
+
log_error("Account ID not found in available accounts")
|
107
|
+
return MCP::Tool::Response.new([{
|
108
|
+
type: "text",
|
109
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
110
|
+
}])
|
111
|
+
end
|
112
|
+
|
113
|
+
log_debug("Found account hash for account ID: #{account_name}")
|
114
|
+
log_debug("Fetching order details for order ID: #{order_id}")
|
115
|
+
|
116
|
+
order_response = client.get_order(order_id, account_hash)
|
117
|
+
|
118
|
+
if order_response&.body
|
119
|
+
log_info("Successfully retrieved order details for order ID: #{order_id}")
|
120
|
+
order_data = JSON.parse(order_response.body)
|
121
|
+
|
122
|
+
formatted_response = format_order_data(order_data, order_id, account_name)
|
123
|
+
|
124
|
+
MCP::Tool::Response.new([{
|
125
|
+
type: "text",
|
126
|
+
text: formatted_response
|
127
|
+
}])
|
128
|
+
else
|
129
|
+
log_warn("Empty response from Schwab API for order ID: #{order_id}")
|
130
|
+
MCP::Tool::Response.new([{
|
131
|
+
type: "text",
|
132
|
+
text: "**No Data**: Empty response from Schwab API for order ID: #{order_id}. Order may not exist or may be in a different account."
|
133
|
+
}])
|
134
|
+
end
|
135
|
+
|
136
|
+
rescue JSON::ParserError => e
|
137
|
+
log_error("JSON parsing error: #{e.message}")
|
138
|
+
MCP::Tool::Response.new([{
|
139
|
+
type: "text",
|
140
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
141
|
+
}])
|
142
|
+
rescue => e
|
143
|
+
log_error("Error retrieving order details for order ID #{order_id}: #{e.message}")
|
144
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
145
|
+
MCP::Tool::Response.new([{
|
146
|
+
type: "text",
|
147
|
+
text: "**Error** retrieving order details for order ID #{order_id}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
148
|
+
}])
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
def self.format_order_data(order_data, order_id, account_name)
|
155
|
+
friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
156
|
+
|
157
|
+
formatted = "**Order Details for Order ID #{order_id}:**\n\n"
|
158
|
+
formatted += "**Account:** #{friendly_name} (#{account_name})\n\n"
|
159
|
+
|
160
|
+
formatted += "**Order Information:**\n"
|
161
|
+
formatted += "- Order ID: #{order_data['orderId']}\n" if order_data['orderId']
|
162
|
+
formatted += "- Status: #{order_data['status']}\n" if order_data['status']
|
163
|
+
formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
|
164
|
+
formatted += "- Session: #{order_data['session']}\n" if order_data['session']
|
165
|
+
formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
|
166
|
+
formatted += "- Complex Order Strategy Type: #{order_data['complexOrderStrategyType']}\n" if order_data['complexOrderStrategyType']
|
167
|
+
formatted += "- Cancelable: #{order_data['cancelable']}\n" if order_data.key?('cancelable')
|
168
|
+
formatted += "- Editable: #{order_data['editable']}\n" if order_data.key?('editable')
|
169
|
+
|
170
|
+
formatted += "\n**Timing:**\n"
|
171
|
+
formatted += "- Entered Time: #{order_data['enteredTime']}\n" if order_data['enteredTime']
|
172
|
+
formatted += "- Close Time: #{order_data['closeTime']}\n" if order_data['closeTime']
|
173
|
+
|
174
|
+
formatted += "\n**Quantity & Pricing:**\n"
|
175
|
+
formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
|
176
|
+
formatted += "- Filled Quantity: #{order_data['filledQuantity']}\n" if order_data['filledQuantity']
|
177
|
+
formatted += "- Remaining Quantity: #{order_data['remainingQuantity']}\n" if order_data['remainingQuantity']
|
178
|
+
formatted += "- Requested Destination: #{order_data['requestedDestination']}\n" if order_data['requestedDestination']
|
179
|
+
formatted += "- Destination Link Name: #{order_data['destinationLinkName']}\n" if order_data['destinationLinkName']
|
180
|
+
formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
|
181
|
+
formatted += "- Stop Price: $#{format_currency(order_data['stopPrice'])}\n" if order_data['stopPrice']
|
182
|
+
formatted += "- Stop Price Link Basis: #{order_data['stopPriceLinkBasis']}\n" if order_data['stopPriceLinkBasis']
|
183
|
+
formatted += "- Stop Price Link Type: #{order_data['stopPriceLinkType']}\n" if order_data['stopPriceLinkType']
|
184
|
+
formatted += "- Stop Price Offset: $#{format_currency(order_data['stopPriceOffset'])}\n" if order_data['stopPriceOffset']
|
185
|
+
formatted += "- Stop Type: #{order_data['stopType']}\n" if order_data['stopType']
|
186
|
+
|
187
|
+
if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
|
188
|
+
formatted += "\n**Order Legs:**\n"
|
189
|
+
order_data['orderLegCollection'].each_with_index do |leg, index|
|
190
|
+
formatted += "**Leg #{index + 1}:**\n"
|
191
|
+
formatted += "- Instruction: #{leg['instruction']}\n" if leg['instruction']
|
192
|
+
formatted += "- Quantity: #{leg['quantity']}\n" if leg['quantity']
|
193
|
+
formatted += "- Position Effect: #{leg['positionEffect']}\n" if leg['positionEffect']
|
194
|
+
formatted += "- Quantity Type: #{leg['quantityType']}\n" if leg['quantityType']
|
195
|
+
|
196
|
+
if leg['instrument']
|
197
|
+
instrument = leg['instrument']
|
198
|
+
formatted += "- **Instrument:**\n"
|
199
|
+
formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
|
200
|
+
formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
|
201
|
+
formatted += " * Description: #{instrument['description']}\n" if instrument['description']
|
202
|
+
formatted += " * CUSIP: #{instrument['cusip']}\n" if instrument['cusip']
|
203
|
+
formatted += " * Net Change: #{instrument['netChange']}\n" if instrument['netChange']
|
204
|
+
|
205
|
+
if instrument['putCall']
|
206
|
+
formatted += " * Option Type: #{instrument['putCall']}\n"
|
207
|
+
formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
|
208
|
+
formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
|
209
|
+
formatted += " * Days to Expiration: #{instrument['daysToExpiration']}\n" if instrument['daysToExpiration']
|
210
|
+
formatted += " * Expiration Type: #{instrument['expirationType']}\n" if instrument['expirationType']
|
211
|
+
formatted += " * Exercise Type: #{instrument['exerciseType']}\n" if instrument['exerciseType']
|
212
|
+
formatted += " * Settlement Type: #{instrument['settlementType']}\n" if instrument['settlementType']
|
213
|
+
formatted += " * Deliverables: #{instrument['deliverables']}\n" if instrument['deliverables']
|
214
|
+
end
|
215
|
+
end
|
216
|
+
formatted += "\n" unless index == order_data['orderLegCollection'].length - 1
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
if order_data['childOrderStrategies'] && order_data['childOrderStrategies'].any?
|
221
|
+
formatted += "\n**Child Order Strategies:**\n"
|
222
|
+
formatted += "- Number of Child Orders: #{order_data['childOrderStrategies'].length}\n"
|
223
|
+
order_data['childOrderStrategies'].each_with_index do |child, index|
|
224
|
+
formatted += "- Child Order #{index + 1}: #{child['orderId']} (Status: #{child['status']})\n" if child['orderId'] && child['status']
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
if order_data['orderActivityCollection'] && order_data['orderActivityCollection'].any?
|
229
|
+
formatted += "\n**Order Activities:**\n"
|
230
|
+
order_data['orderActivityCollection'].each_with_index do |activity, index|
|
231
|
+
formatted += "**Activity #{index + 1}:**\n"
|
232
|
+
formatted += "- Activity Type: #{activity['activityType']}\n" if activity['activityType']
|
233
|
+
formatted += "- Execution Type: #{activity['executionType']}\n" if activity['executionType']
|
234
|
+
formatted += "- Quantity: #{activity['quantity']}\n" if activity['quantity']
|
235
|
+
formatted += "- Order Remaining Quantity: #{activity['orderRemainingQuantity']}\n" if activity['orderRemainingQuantity']
|
236
|
+
|
237
|
+
if activity['executionLegs'] && activity['executionLegs'].any?
|
238
|
+
activity['executionLegs'].each_with_index do |exec_leg, leg_index|
|
239
|
+
formatted += "- **Execution Leg #{leg_index + 1}:**\n"
|
240
|
+
formatted += " * Leg ID: #{exec_leg['legId']}\n" if exec_leg['legId']
|
241
|
+
formatted += " * Price: $#{format_currency(exec_leg['price'])}\n" if exec_leg['price']
|
242
|
+
formatted += " * Quantity: #{exec_leg['quantity']}\n" if exec_leg['quantity']
|
243
|
+
formatted += " * Mismarked Quantity: #{exec_leg['mismarkedQuantity']}\n" if exec_leg['mismarkedQuantity']
|
244
|
+
formatted += " * Time: #{exec_leg['time']}\n" if exec_leg['time']
|
245
|
+
end
|
246
|
+
end
|
247
|
+
formatted += "\n" unless index == order_data['orderActivityCollection'].length - 1
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
redacted_data = Redactor.redact(order_data)
|
252
|
+
formatted += "\n**Full Response (Redacted):**\n"
|
253
|
+
formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
254
|
+
formatted
|
255
|
+
end
|
256
|
+
|
257
|
+
def self.format_currency(amount)
|
258
|
+
return "0.00" if amount.nil?
|
259
|
+
"%.2f" % amount.to_f
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|