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,295 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require "date"
|
5
|
+
require_relative "../loggable"
|
6
|
+
require_relative "../redactor"
|
7
|
+
|
8
|
+
module SchwabMCP
|
9
|
+
module Tools
|
10
|
+
class ListAccountOrdersTool < MCP::Tool
|
11
|
+
extend Loggable
|
12
|
+
description "List orders for a specific account using account name mapping"
|
13
|
+
|
14
|
+
input_schema(
|
15
|
+
properties: {
|
16
|
+
account_name: {
|
17
|
+
type: "string",
|
18
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
19
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
20
|
+
},
|
21
|
+
max_results: {
|
22
|
+
type: "integer",
|
23
|
+
description: "Maximum number of orders to retrieve (optional)",
|
24
|
+
minimum: 1
|
25
|
+
},
|
26
|
+
from_date: {
|
27
|
+
type: "string",
|
28
|
+
description: "Start date for orders in YYYY-MM-DD format",
|
29
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
|
30
|
+
},
|
31
|
+
to_date: {
|
32
|
+
type: "string",
|
33
|
+
description: "End date for orders in YYYY-MM-DD format",
|
34
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
|
35
|
+
},
|
36
|
+
status: {
|
37
|
+
type: "string",
|
38
|
+
description: "Filter orders by status (AWAITING_PARENT_ORDER, AWAITING_CONDITION, AWAITING_STOP_CONDITION, AWAITING_MANUAL_REVIEW, ACCEPTED, AWAITING_UR_OUT, PENDING_ACTIVATION, QUEUED, WORKING, REJECTED, PENDING_CANCEL, CANCELED, PENDING_REPLACE, REPLACED, FILLED, EXPIRED, NEW, AWAITING_RELEASE_TIME, PENDING_ACKNOWLEDGEMENT, PENDING_RECALL, UNKNOWN)",
|
39
|
+
enum: [
|
40
|
+
"AWAITING_PARENT_ORDER",
|
41
|
+
"AWAITING_CONDITION",
|
42
|
+
"AWAITING_STOP_CONDITION",
|
43
|
+
"AWAITING_MANUAL_REVIEW",
|
44
|
+
"ACCEPTED",
|
45
|
+
"AWAITING_UR_OUT",
|
46
|
+
"PENDING_ACTIVATION",
|
47
|
+
"QUEUED",
|
48
|
+
"WORKING",
|
49
|
+
"REJECTED",
|
50
|
+
"PENDING_CANCEL",
|
51
|
+
"CANCELED",
|
52
|
+
"PENDING_REPLACE",
|
53
|
+
"REPLACED",
|
54
|
+
"FILLED",
|
55
|
+
"EXPIRED",
|
56
|
+
"NEW",
|
57
|
+
"AWAITING_RELEASE_TIME",
|
58
|
+
"PENDING_ACKNOWLEDGEMENT",
|
59
|
+
"PENDING_RECALL",
|
60
|
+
"UNKNOWN"
|
61
|
+
]
|
62
|
+
}
|
63
|
+
},
|
64
|
+
required: ["account_name", "from_date", "to_date"]
|
65
|
+
)
|
66
|
+
|
67
|
+
annotations(
|
68
|
+
title: "List Account Orders",
|
69
|
+
read_only_hint: true,
|
70
|
+
destructive_hint: false,
|
71
|
+
idempotent_hint: true
|
72
|
+
)
|
73
|
+
|
74
|
+
def self.call(account_name:, max_results: nil, from_date: nil, to_date: nil, status: nil, server_context:)
|
75
|
+
log_info("Listing orders for account name: #{account_name}")
|
76
|
+
|
77
|
+
unless account_name.end_with?('_ACCOUNT')
|
78
|
+
log_error("Invalid account name format: #{account_name}")
|
79
|
+
return MCP::Tool::Response.new([{
|
80
|
+
type: "text",
|
81
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
82
|
+
}])
|
83
|
+
end
|
84
|
+
|
85
|
+
begin
|
86
|
+
client = SchwabRb::Auth.init_client_easy(
|
87
|
+
ENV['SCHWAB_API_KEY'],
|
88
|
+
ENV['SCHWAB_APP_SECRET'],
|
89
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
90
|
+
ENV['TOKEN_PATH']
|
91
|
+
)
|
92
|
+
|
93
|
+
unless client
|
94
|
+
log_error("Failed to initialize Schwab client")
|
95
|
+
return MCP::Tool::Response.new([{
|
96
|
+
type: "text",
|
97
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
98
|
+
}])
|
99
|
+
end
|
100
|
+
|
101
|
+
account_id = ENV[account_name]
|
102
|
+
unless account_id
|
103
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
104
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
105
|
+
return MCP::Tool::Response.new([{
|
106
|
+
type: "text",
|
107
|
+
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."
|
108
|
+
}])
|
109
|
+
end
|
110
|
+
|
111
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
112
|
+
log_debug("Fetching account numbers mapping")
|
113
|
+
|
114
|
+
account_numbers_response = client.get_account_numbers
|
115
|
+
|
116
|
+
unless account_numbers_response&.body
|
117
|
+
log_error("Failed to retrieve account numbers")
|
118
|
+
return MCP::Tool::Response.new([{
|
119
|
+
type: "text",
|
120
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
121
|
+
}])
|
122
|
+
end
|
123
|
+
|
124
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
125
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
126
|
+
|
127
|
+
account_hash = nil
|
128
|
+
account_mappings.each do |mapping|
|
129
|
+
if mapping[:accountNumber] == account_id
|
130
|
+
account_hash = mapping[:hashValue]
|
131
|
+
break
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
unless account_hash
|
136
|
+
log_error("Account ID not found in available accounts")
|
137
|
+
return MCP::Tool::Response.new([{
|
138
|
+
type: "text",
|
139
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
140
|
+
}])
|
141
|
+
end
|
142
|
+
|
143
|
+
log_debug("Found account hash for account ID: #{account_name}")
|
144
|
+
|
145
|
+
from_datetime = nil
|
146
|
+
to_datetime = nil
|
147
|
+
|
148
|
+
if from_date
|
149
|
+
begin
|
150
|
+
from_datetime = DateTime.parse("#{from_date}T00:00:00Z")
|
151
|
+
rescue Date::Error => e
|
152
|
+
log_error("Invalid from_date format: #{from_date}")
|
153
|
+
return MCP::Tool::Response.new([{
|
154
|
+
type: "text",
|
155
|
+
text: "**Error**: Invalid from_date format. Use YYYY-MM-DD format."
|
156
|
+
}])
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
if to_date
|
161
|
+
begin
|
162
|
+
to_datetime = DateTime.parse("#{to_date}T23:59:59Z")
|
163
|
+
rescue Date::Error => e
|
164
|
+
log_error("Invalid to_date format: #{to_date}")
|
165
|
+
return MCP::Tool::Response.new([{
|
166
|
+
type: "text",
|
167
|
+
text: "**Error**: Invalid to_date format. Use YYYY-MM-DD format."
|
168
|
+
}])
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
log_debug("Fetching orders with params - max_results: #{max_results}, from_datetime: #{from_datetime}, to_datetime: #{to_datetime}, status: #{status}")
|
173
|
+
|
174
|
+
orders_response = client.get_account_orders(
|
175
|
+
account_hash,
|
176
|
+
max_results: max_results,
|
177
|
+
from_entered_datetime: from_datetime,
|
178
|
+
to_entered_datetime: to_datetime,
|
179
|
+
status: status
|
180
|
+
)
|
181
|
+
|
182
|
+
if orders_response&.body
|
183
|
+
log_info("Successfully retrieved orders for #{account_name}")
|
184
|
+
orders_data = JSON.parse(orders_response.body)
|
185
|
+
|
186
|
+
formatted_response = format_orders_data(orders_data, account_name, {
|
187
|
+
max_results: max_results,
|
188
|
+
from_date: from_date,
|
189
|
+
to_date: to_date,
|
190
|
+
status: status
|
191
|
+
})
|
192
|
+
|
193
|
+
MCP::Tool::Response.new([{
|
194
|
+
type: "text",
|
195
|
+
text: formatted_response
|
196
|
+
}])
|
197
|
+
else
|
198
|
+
log_warn("Empty response from Schwab API for account: #{account_name}")
|
199
|
+
MCP::Tool::Response.new([{
|
200
|
+
type: "text",
|
201
|
+
text: "**No Data**: Empty response from Schwab API for account: #{account_name}"
|
202
|
+
}])
|
203
|
+
end
|
204
|
+
|
205
|
+
rescue JSON::ParserError => e
|
206
|
+
log_error("JSON parsing error: #{e.message}")
|
207
|
+
MCP::Tool::Response.new([{
|
208
|
+
type: "text",
|
209
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
210
|
+
}])
|
211
|
+
rescue => e
|
212
|
+
log_error("Error retrieving orders for #{account_name}: #{e.message}")
|
213
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
214
|
+
MCP::Tool::Response.new([{
|
215
|
+
type: "text",
|
216
|
+
text: "**Error** retrieving orders for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
217
|
+
}])
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
private
|
222
|
+
|
223
|
+
def self.format_orders_data(orders_data, account_name, filters)
|
224
|
+
friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
225
|
+
|
226
|
+
formatted = "**Orders for #{friendly_name} (#{account_name}):**\n\n"
|
227
|
+
|
228
|
+
if filters.any? { |k, v| v }
|
229
|
+
formatted += "**Filters Applied:**\n"
|
230
|
+
formatted += "- Max Results: #{filters[:max_results]}\n" if filters[:max_results]
|
231
|
+
formatted += "- From Date: #{filters[:from_date]}\n" if filters[:from_date]
|
232
|
+
formatted += "- To Date: #{filters[:to_date]}\n" if filters[:to_date]
|
233
|
+
formatted += "- Status: #{filters[:status]}\n" if filters[:status]
|
234
|
+
formatted += "\n"
|
235
|
+
end
|
236
|
+
|
237
|
+
if orders_data.is_a?(Array)
|
238
|
+
orders = orders_data
|
239
|
+
else
|
240
|
+
orders = [orders_data]
|
241
|
+
end
|
242
|
+
|
243
|
+
formatted += "**Orders Summary:**\n"
|
244
|
+
formatted += "- Total Orders: #{orders.length}\n\n"
|
245
|
+
|
246
|
+
if orders.length > 0
|
247
|
+
formatted += "**Order Details:**\n"
|
248
|
+
orders.each_with_index do |order, index|
|
249
|
+
formatted += format_single_order(order, index + 1)
|
250
|
+
formatted += "\n" unless index == orders.length - 1
|
251
|
+
end
|
252
|
+
else
|
253
|
+
formatted += "No orders found matching the specified criteria.\n"
|
254
|
+
end
|
255
|
+
|
256
|
+
redacted_data = Redactor.redact(orders_data)
|
257
|
+
formatted += "\n**Full Response (Redacted):**\n"
|
258
|
+
formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
259
|
+
formatted
|
260
|
+
end
|
261
|
+
|
262
|
+
def self.format_single_order(order, order_num)
|
263
|
+
formatted = "**Order #{order_num}:**\n"
|
264
|
+
formatted += "- Order ID: #{order['orderId']}\n" if order['orderId']
|
265
|
+
formatted += "- Status: #{order['status']}\n" if order['status']
|
266
|
+
formatted += "- Order Type: #{order['orderType']}\n" if order['orderType']
|
267
|
+
formatted += "- Session: #{order['session']}\n" if order['session']
|
268
|
+
formatted += "- Duration: #{order['duration']}\n" if order['duration']
|
269
|
+
formatted += "- Entered Time: #{order['enteredTime']}\n" if order['enteredTime']
|
270
|
+
formatted += "- Close Time: #{order['closeTime']}\n" if order['closeTime']
|
271
|
+
formatted += "- Quantity: #{order['quantity']}\n" if order['quantity']
|
272
|
+
formatted += "- Filled Quantity: #{order['filledQuantity']}\n" if order['filledQuantity']
|
273
|
+
formatted += "- Price: $#{format_currency(order['price'])}\n" if order['price']
|
274
|
+
|
275
|
+
if order['orderLegCollection'] && order['orderLegCollection'].any?
|
276
|
+
formatted += "- Instruments:\n"
|
277
|
+
order['orderLegCollection'].each do |leg|
|
278
|
+
if leg['instrument']
|
279
|
+
symbol = leg['instrument']['symbol']
|
280
|
+
instruction = leg['instruction']
|
281
|
+
formatted += " * #{symbol} - #{instruction}\n"
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
formatted
|
287
|
+
end
|
288
|
+
|
289
|
+
def self.format_currency(amount)
|
290
|
+
return "0.00" if amount.nil?
|
291
|
+
"%.2f" % amount.to_f
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,311 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require "date"
|
5
|
+
require_relative "../loggable"
|
6
|
+
require_relative "../redactor"
|
7
|
+
|
8
|
+
module SchwabMCP
|
9
|
+
module Tools
|
10
|
+
class ListAccountTransactionsTool < MCP::Tool
|
11
|
+
extend Loggable
|
12
|
+
description "List transactions for a specific account using account name mapping"
|
13
|
+
|
14
|
+
input_schema(
|
15
|
+
properties: {
|
16
|
+
account_name: {
|
17
|
+
type: "string",
|
18
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
19
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
20
|
+
},
|
21
|
+
start_date: {
|
22
|
+
type: "string",
|
23
|
+
description: "Start date for transactions in YYYY-MM-DD format (default: 60 days ago)",
|
24
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
|
25
|
+
},
|
26
|
+
end_date: {
|
27
|
+
type: "string",
|
28
|
+
description: "End date for transactions in YYYY-MM-DD format (default: today)",
|
29
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$"
|
30
|
+
},
|
31
|
+
transaction_types: {
|
32
|
+
type: "array",
|
33
|
+
description: "Array of transaction types to filter by (optional, if not provided all types will be included)",
|
34
|
+
items: {
|
35
|
+
type: "string",
|
36
|
+
enum: [
|
37
|
+
"TRADE",
|
38
|
+
"RECEIVE_AND_DELIVER",
|
39
|
+
"DIVIDEND_OR_INTEREST",
|
40
|
+
"ACH_RECEIPT",
|
41
|
+
"ACH_DISBURSEMENT",
|
42
|
+
"CASH_RECEIPT",
|
43
|
+
"CASH_DISBURSEMENT",
|
44
|
+
"ELECTRONIC_FUND",
|
45
|
+
"WIRE_OUT",
|
46
|
+
"WIRE_IN",
|
47
|
+
"JOURNAL",
|
48
|
+
"MEMORANDUM",
|
49
|
+
"MARGIN_CALL",
|
50
|
+
"MONEY_MARKET",
|
51
|
+
"SMA_ADJUSTMENT"
|
52
|
+
]
|
53
|
+
}
|
54
|
+
},
|
55
|
+
symbol: {
|
56
|
+
type: "string",
|
57
|
+
description: "Filter transactions by the specified symbol (optional)"
|
58
|
+
}
|
59
|
+
},
|
60
|
+
required: ["account_name"]
|
61
|
+
)
|
62
|
+
|
63
|
+
annotations(
|
64
|
+
title: "List Account Transactions",
|
65
|
+
read_only_hint: true,
|
66
|
+
destructive_hint: false,
|
67
|
+
idempotent_hint: true
|
68
|
+
)
|
69
|
+
|
70
|
+
def self.call(account_name:, start_date: nil, end_date: nil, transaction_types: nil, symbol: nil, server_context:)
|
71
|
+
log_info("Listing transactions for account name: #{account_name}")
|
72
|
+
|
73
|
+
unless account_name.end_with?('_ACCOUNT')
|
74
|
+
log_error("Invalid account name format: #{account_name}")
|
75
|
+
return MCP::Tool::Response.new([{
|
76
|
+
type: "text",
|
77
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
78
|
+
}])
|
79
|
+
end
|
80
|
+
|
81
|
+
begin
|
82
|
+
client = SchwabRb::Auth.init_client_easy(
|
83
|
+
ENV['SCHWAB_API_KEY'],
|
84
|
+
ENV['SCHWAB_APP_SECRET'],
|
85
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
86
|
+
ENV['TOKEN_PATH']
|
87
|
+
)
|
88
|
+
|
89
|
+
unless client
|
90
|
+
log_error("Failed to initialize Schwab client")
|
91
|
+
return MCP::Tool::Response.new([{
|
92
|
+
type: "text",
|
93
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
94
|
+
}])
|
95
|
+
end
|
96
|
+
|
97
|
+
account_id = ENV[account_name]
|
98
|
+
unless account_id
|
99
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
100
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
101
|
+
return MCP::Tool::Response.new([{
|
102
|
+
type: "text",
|
103
|
+
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."
|
104
|
+
}])
|
105
|
+
end
|
106
|
+
|
107
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
108
|
+
log_debug("Fetching account numbers mapping")
|
109
|
+
|
110
|
+
account_numbers_response = client.get_account_numbers
|
111
|
+
|
112
|
+
unless account_numbers_response&.body
|
113
|
+
log_error("Failed to retrieve account numbers")
|
114
|
+
return MCP::Tool::Response.new([{
|
115
|
+
type: "text",
|
116
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
117
|
+
}])
|
118
|
+
end
|
119
|
+
|
120
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
121
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
122
|
+
|
123
|
+
account_hash = nil
|
124
|
+
account_mappings.each do |mapping|
|
125
|
+
if mapping[:accountNumber] == account_id
|
126
|
+
account_hash = mapping[:hashValue]
|
127
|
+
break
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
unless account_hash
|
132
|
+
log_error("Account ID not found in available accounts")
|
133
|
+
return MCP::Tool::Response.new([{
|
134
|
+
type: "text",
|
135
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
136
|
+
}])
|
137
|
+
end
|
138
|
+
|
139
|
+
log_debug("Found account hash for account ID: #{account_name}")
|
140
|
+
|
141
|
+
start_date_obj = nil
|
142
|
+
end_date_obj = nil
|
143
|
+
|
144
|
+
if start_date
|
145
|
+
begin
|
146
|
+
start_date_obj = DateTime.parse("#{start_date}T00:00:00Z")
|
147
|
+
rescue Date::Error => e
|
148
|
+
log_error("Invalid start_date format: #{start_date}")
|
149
|
+
return MCP::Tool::Response.new([{
|
150
|
+
type: "text",
|
151
|
+
text: "**Error**: Invalid start_date format. Use YYYY-MM-DD format."
|
152
|
+
}])
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
if end_date
|
157
|
+
begin
|
158
|
+
end_date_obj = DateTime.parse("#{end_date}T23:59:59Z")
|
159
|
+
rescue Date::Error => e
|
160
|
+
log_error("Invalid end_date format: #{end_date}")
|
161
|
+
return MCP::Tool::Response.new([{
|
162
|
+
type: "text",
|
163
|
+
text: "**Error**: Invalid end_date format. Use YYYY-MM-DD format."
|
164
|
+
}])
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
log_debug("Fetching transactions with params - start_date: #{start_date_obj}, end_date: #{end_date_obj}, transaction_types: #{transaction_types}, symbol: #{symbol}")
|
169
|
+
|
170
|
+
transactions_response = client.get_transactions(
|
171
|
+
account_hash,
|
172
|
+
start_date: start_date_obj,
|
173
|
+
end_date: end_date_obj,
|
174
|
+
transaction_types: transaction_types,
|
175
|
+
symbol: symbol
|
176
|
+
)
|
177
|
+
|
178
|
+
if transactions_response&.body
|
179
|
+
log_info("Successfully retrieved transactions for #{account_name}")
|
180
|
+
transactions_data = JSON.parse(transactions_response.body)
|
181
|
+
|
182
|
+
formatted_response = format_transactions_data(transactions_data, account_name, {
|
183
|
+
start_date: start_date,
|
184
|
+
end_date: end_date,
|
185
|
+
transaction_types: transaction_types,
|
186
|
+
symbol: symbol
|
187
|
+
})
|
188
|
+
|
189
|
+
MCP::Tool::Response.new([{
|
190
|
+
type: "text",
|
191
|
+
text: formatted_response
|
192
|
+
}])
|
193
|
+
else
|
194
|
+
log_warn("Empty response from Schwab API for account: #{account_name}")
|
195
|
+
MCP::Tool::Response.new([{
|
196
|
+
type: "text",
|
197
|
+
text: "**No Data**: Empty response from Schwab API for account: #{account_name}"
|
198
|
+
}])
|
199
|
+
end
|
200
|
+
|
201
|
+
rescue JSON::ParserError => e
|
202
|
+
log_error("JSON parsing error: #{e.message}")
|
203
|
+
MCP::Tool::Response.new([{
|
204
|
+
type: "text",
|
205
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
206
|
+
}])
|
207
|
+
rescue => e
|
208
|
+
log_error("Error retrieving transactions for #{account_name}: #{e.message}")
|
209
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
210
|
+
MCP::Tool::Response.new([{
|
211
|
+
type: "text",
|
212
|
+
text: "**Error** retrieving transactions for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
213
|
+
}])
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
private
|
218
|
+
|
219
|
+
def self.format_transactions_data(transactions_data, account_name, filters)
|
220
|
+
friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
221
|
+
|
222
|
+
formatted = "**Transactions for #{friendly_name} (#{account_name}):**\n\n"
|
223
|
+
|
224
|
+
if filters.any? { |k, v| v }
|
225
|
+
formatted += "**Filters Applied:**\n"
|
226
|
+
formatted += "- Start Date: #{filters[:start_date]}\n" if filters[:start_date]
|
227
|
+
formatted += "- End Date: #{filters[:end_date]}\n" if filters[:end_date]
|
228
|
+
formatted += "- Transaction Types: #{filters[:transaction_types].join(', ')}\n" if filters[:transaction_types]&.any?
|
229
|
+
formatted += "- Symbol: #{filters[:symbol]}\n" if filters[:symbol]
|
230
|
+
formatted += "\n"
|
231
|
+
end
|
232
|
+
|
233
|
+
transactions = transactions_data.is_a?(Array) ? transactions_data : [transactions_data]
|
234
|
+
|
235
|
+
formatted += "**Transactions Summary:**\n"
|
236
|
+
formatted += "- Total Transactions: #{transactions.length}\n\n"
|
237
|
+
|
238
|
+
if transactions.length > 0
|
239
|
+
transactions_by_type = transactions.group_by { |t| t['type'] }
|
240
|
+
formatted += "**Transactions by Type:**\n"
|
241
|
+
transactions_by_type.each do |type, type_transactions|
|
242
|
+
formatted += "- #{type}: #{type_transactions.length} transactions\n"
|
243
|
+
end
|
244
|
+
formatted += "\n"
|
245
|
+
|
246
|
+
formatted += "**Transaction Details:**\n"
|
247
|
+
transactions.each_with_index do |transaction, index|
|
248
|
+
formatted += format_single_transaction(transaction, index + 1)
|
249
|
+
formatted += "\n" unless index == transactions.length - 1
|
250
|
+
end
|
251
|
+
else
|
252
|
+
formatted += "No transactions found matching the specified criteria.\n"
|
253
|
+
end
|
254
|
+
|
255
|
+
redacted_data = Redactor.redact(transactions_data)
|
256
|
+
formatted += "\n**Full Response (Redacted):**\n"
|
257
|
+
formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
258
|
+
formatted
|
259
|
+
end
|
260
|
+
|
261
|
+
def self.format_single_transaction(transaction, transaction_num)
|
262
|
+
formatted = "**Transaction #{transaction_num}:**\n"
|
263
|
+
formatted += "- Activity ID: #{transaction['activityId']}\n" if transaction['activityId']
|
264
|
+
formatted += "- Type: #{transaction['type']}\n" if transaction['type']
|
265
|
+
formatted += "- Status: #{transaction['status']}\n" if transaction['status']
|
266
|
+
formatted += "- Trade Date: #{transaction['tradeDate']}\n" if transaction['tradeDate']
|
267
|
+
formatted += "- Settlement Date: #{transaction['settlementDate']}\n" if transaction['settlementDate']
|
268
|
+
formatted += "- Net Amount: $#{format_currency(transaction['netAmount'])}\n" if transaction['netAmount']
|
269
|
+
formatted += "- Sub Account: #{transaction['subAccount']}\n" if transaction['subAccount']
|
270
|
+
formatted += "- Order ID: #{transaction['orderId']}\n" if transaction['orderId']
|
271
|
+
formatted += "- Position ID: #{transaction['positionId']}\n" if transaction['positionId']
|
272
|
+
|
273
|
+
if transaction['transferItems'] && transaction['transferItems'].any?
|
274
|
+
formatted += "- Transfer Items:\n"
|
275
|
+
transaction['transferItems'].each_with_index do |item, i|
|
276
|
+
formatted += " * Item #{i + 1}:\n"
|
277
|
+
formatted += " - Amount: $#{format_currency(item['amount'])}\n" if item['amount']
|
278
|
+
formatted += " - Cost: $#{format_currency(item['cost'])}\n" if item['cost']
|
279
|
+
formatted += " - Price: $#{format_currency(item['price'])}\n" if item['price']
|
280
|
+
formatted += " - Fee Type: #{item['feeType']}\n" if item['feeType']
|
281
|
+
formatted += " - Position Effect: #{item['positionEffect']}\n" if item['positionEffect']
|
282
|
+
|
283
|
+
if item['instrument']
|
284
|
+
instrument = item['instrument']
|
285
|
+
formatted += " - Instrument:\n"
|
286
|
+
formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
|
287
|
+
formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
|
288
|
+
formatted += " * Description: #{instrument['description']}\n" if instrument['description']
|
289
|
+
formatted += " * Closing Price: $#{format_currency(instrument['closingPrice'])}\n" if instrument['closingPrice']
|
290
|
+
|
291
|
+
# Options-specific fields
|
292
|
+
if instrument['assetType'] == 'OPTION'
|
293
|
+
formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
|
294
|
+
formatted += " * Put/Call: #{instrument['putCall']}\n" if instrument['putCall']
|
295
|
+
formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
|
296
|
+
formatted += " * Underlying Symbol: #{instrument['underlyingSymbol']}\n" if instrument['underlyingSymbol']
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
formatted
|
303
|
+
end
|
304
|
+
|
305
|
+
def self.format_currency(amount)
|
306
|
+
return "0.00" if amount.nil?
|
307
|
+
"%.2f" % amount.to_f
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|