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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.copilotignore +3 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +157 -0
  9. data/Rakefile +12 -0
  10. data/exe/schwab_mcp +19 -0
  11. data/exe/schwab_token_refresh +38 -0
  12. data/exe/schwab_token_reset +49 -0
  13. data/lib/schwab_mcp/loggable.rb +31 -0
  14. data/lib/schwab_mcp/logger.rb +62 -0
  15. data/lib/schwab_mcp/option_chain_filter.rb +213 -0
  16. data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
  17. data/lib/schwab_mcp/orders/order_factory.rb +40 -0
  18. data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
  19. data/lib/schwab_mcp/redactor.rb +210 -0
  20. data/lib/schwab_mcp/resources/.keep +0 -0
  21. data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
  22. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
  23. data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
  24. data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
  25. data/lib/schwab_mcp/tools/help_tool.rb +406 -0
  26. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
  27. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
  28. data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
  29. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
  30. data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
  31. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
  32. data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
  33. data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
  34. data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
  35. data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
  36. data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
  37. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
  38. data/lib/schwab_mcp/version.rb +5 -0
  39. data/lib/schwab_mcp.rb +107 -0
  40. data/sig/schwab_mcp.rbs +4 -0
  41. data/start_mcp_server.sh +4 -0
  42. metadata +115 -0
@@ -0,0 +1,378 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require "date"
5
+ require_relative "../loggable"
6
+ require_relative "../option_chain_filter"
7
+
8
+ module SchwabMCP
9
+ module Tools
10
+ class OptionStrategyFinderTool < MCP::Tool
11
+ extend Loggable
12
+ description "Find option strategies (iron condor, call spread, put spread) using Schwab API"
13
+
14
+ input_schema(
15
+ properties: {
16
+ strategy_type: {
17
+ type: "string",
18
+ description: "Type of option strategy to find",
19
+ enum: ["ironcondor", "callspread", "putspread"]
20
+ },
21
+ underlying_symbol: {
22
+ type: "string",
23
+ description: "Underlying symbol for the options (e.g., '$SPX', 'SPY')",
24
+ pattern: "^[A-Za-z$]{1,6}$"
25
+ },
26
+ expiration_date: {
27
+ type: "string",
28
+ description: "Target expiration date for options (YYYY-MM-DD format)"
29
+ },
30
+ expiration_type: {
31
+ type: "string",
32
+ description: "Type of expiration (e.g., 'W' for weekly, 'M' for monthly)",
33
+ enum: ["W", "M", "Q"]
34
+ },
35
+ settlement_type: {
36
+ type: "string",
37
+ description: "Settlement type (e.g., 'P' for PM settled, 'A' for AM settled)",
38
+ enum: ["P", "A"]
39
+ },
40
+ option_root: {
41
+ type: "string",
42
+ description: "Option root symbol (e.g., 'SPXW' for weekly SPX options)"
43
+ },
44
+ max_delta: {
45
+ type: "number",
46
+ description: "Maximum absolute delta for short legs (default: 0.15)",
47
+ minimum: 0.01,
48
+ maximum: 1.0
49
+ },
50
+ max_spread: {
51
+ type: "number",
52
+ description: "Maximum spread width in dollars (default: 20.0)",
53
+ minimum: 1.0
54
+ },
55
+ min_credit: {
56
+ type: "number",
57
+ description: "Minimum credit received in dollars (default: 100.0)",
58
+ minimum: 0.01
59
+ },
60
+ min_open_interest: {
61
+ type: "integer",
62
+ description: "Minimum open interest for options (default: 0)",
63
+ minimum: 0
64
+ },
65
+ dist_from_strike: {
66
+ type: "number",
67
+ description: "Minimum distance from current price as percentage (default: 0.07)",
68
+ minimum: 0.0,
69
+ maximum: 1.0
70
+ },
71
+ quantity: {
72
+ type: "integer",
73
+ description: "Number of contracts per leg (default: 1)",
74
+ minimum: 1
75
+ },
76
+ from_date: {
77
+ type: "string",
78
+ description: "Start date for expiration search (YYYY-MM-DD format)"
79
+ },
80
+ to_date: {
81
+ type: "string",
82
+ description: "End date for expiration search (YYYY-MM-DD format)"
83
+ }
84
+ },
85
+ required: ["strategy_type", "underlying_symbol", "expiration_date"]
86
+ )
87
+
88
+ annotations(
89
+ title: "Find Option Strategy",
90
+ read_only_hint: true,
91
+ destructive_hint: false,
92
+ idempotent_hint: true
93
+ )
94
+
95
+ def self.call(strategy_type:, underlying_symbol:, expiration_date:,
96
+ expiration_type: nil, settlement_type: nil, option_root: nil,
97
+ max_delta: 0.15, max_spread: 20.0, min_credit: 0.0,
98
+ min_open_interest: 0, dist_from_strike: 0.0, quantity: 1,
99
+ from_date: nil, to_date: nil, server_context:)
100
+
101
+ log_info("Finding #{strategy_type} strategy for #{underlying_symbol} expiring #{expiration_date}")
102
+
103
+ begin
104
+ unless %w[ironcondor callspread putspread].include?(strategy_type.downcase)
105
+ return MCP::Tool::Response.new([{
106
+ type: "text",
107
+ text: "**Error**: Invalid strategy type '#{strategy_type}'. Must be one of: ironcondor, callspread, putspread"
108
+ }])
109
+ end
110
+
111
+ client = SchwabRb::Auth.init_client_easy(
112
+ ENV['SCHWAB_API_KEY'],
113
+ ENV['SCHWAB_APP_SECRET'],
114
+ ENV['SCHWAB_CALLBACK_URI'],
115
+ ENV['TOKEN_PATH']
116
+ )
117
+
118
+ unless client
119
+ log_error("Failed to initialize Schwab client")
120
+ return MCP::Tool::Response.new([{
121
+ type: "text",
122
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
123
+ }])
124
+ end
125
+
126
+ exp_date = Date.parse(expiration_date)
127
+ from_dt = from_date ? Date.parse(from_date) : exp_date
128
+ to_dt = to_date ? Date.parse(to_date) : exp_date
129
+
130
+ contract_type = strategy_type.downcase == 'callspread' ? 'CALL' :
131
+ strategy_type.downcase == 'putspread' ? 'PUT' : 'ALL'
132
+
133
+ log_debug("Fetching option chain for #{underlying_symbol} (#{contract_type})")
134
+
135
+ response = client.get_option_chain(
136
+ underlying_symbol.upcase,
137
+ contract_type: contract_type,
138
+ from_date: from_dt,
139
+ to_date: to_dt,
140
+ include_underlying_quote: true
141
+ )
142
+
143
+ unless response&.body
144
+ log_warn("Empty response from Schwab API for #{underlying_symbol}")
145
+ return MCP::Tool::Response.new([{
146
+ type: "text",
147
+ text: "**No Data**: Could not retrieve option chain for #{underlying_symbol}"
148
+ }])
149
+ end
150
+
151
+ option_data = JSON.parse(response.body, symbolize_names: true)
152
+
153
+ result = find_strategy(
154
+ strategy_type: strategy_type.downcase,
155
+ option_data: option_data,
156
+ underlying_symbol: underlying_symbol,
157
+ expiration_date: exp_date,
158
+ expiration_type: expiration_type,
159
+ settlement_type: settlement_type,
160
+ option_root: option_root,
161
+ max_delta: max_delta,
162
+ max_spread: max_spread,
163
+ min_credit: min_credit,
164
+ min_open_interest: min_open_interest,
165
+ dist_from_strike: dist_from_strike,
166
+ quantity: quantity
167
+ )
168
+
169
+ if result.nil? || result[:status] == 'not_found'
170
+ log_info("No suitable #{strategy_type} found for #{underlying_symbol}")
171
+ return MCP::Tool::Response.new([{
172
+ type: "text",
173
+ text: "**No Strategy Found**: Could not find a suitable #{strategy_type} for #{underlying_symbol} with the specified criteria."
174
+ }])
175
+ else
176
+ log_info("Found #{strategy_type} strategy for #{underlying_symbol}")
177
+ return MCP::Tool::Response.new([{
178
+ type: "text",
179
+ text: format_strategy_result(result, strategy_type)
180
+ }])
181
+ end
182
+ rescue Date::Error => e
183
+ log_error("Invalid date format: #{e.message}")
184
+ return MCP::Tool::Response.new([{
185
+ type: "text",
186
+ text: "**Error**: Invalid date format. Use YYYY-MM-DD format."
187
+ }])
188
+ rescue JSON::ParserError => e
189
+ log_error("Failed to parse option chain data: #{e.message}")
190
+ return MCP::Tool::Response.new([{
191
+ type: "text",
192
+ text: "**Error**: Failed to parse option chain data from Schwab API."
193
+ }])
194
+ rescue => e
195
+ log_error("Error finding #{strategy_type} for #{underlying_symbol}: #{e.message}")
196
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
197
+ return MCP::Tool::Response.new([{
198
+ type: "text",
199
+ text: "**Error** finding #{strategy_type} for #{underlying_symbol}: #{e.message}"
200
+ }])
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def self.find_strategy(strategy_type:, option_data:, underlying_symbol:, expiration_date:,
207
+ expiration_type:, settlement_type:, option_root:, max_delta:,
208
+ max_spread:, min_credit:, min_open_interest:, dist_from_strike:, quantity:)
209
+
210
+ case strategy_type
211
+ when 'ironcondor'
212
+ find_iron_condor(option_data, underlying_symbol, expiration_date, expiration_type,
213
+ settlement_type, option_root, max_delta, max_spread, min_credit / 2.0,
214
+ min_open_interest, dist_from_strike, quantity)
215
+ when 'callspread'
216
+ find_spread(option_data, 'call', underlying_symbol, expiration_date, expiration_type,
217
+ settlement_type, option_root, max_delta, max_spread, min_credit,
218
+ min_open_interest, dist_from_strike, quantity)
219
+ when 'putspread'
220
+ find_spread(option_data, 'put', underlying_symbol, expiration_date, expiration_type,
221
+ settlement_type, option_root, max_delta, max_spread, min_credit,
222
+ min_open_interest, dist_from_strike, quantity)
223
+ end
224
+ end
225
+
226
+ def self.find_iron_condor(option_data, underlying_symbol, expiration_date, expiration_type,
227
+ settlement_type, option_root, max_delta, max_spread, min_credit,
228
+ min_open_interest, dist_from_strike, quantity)
229
+
230
+ underlying_price = option_data.dig(:underlyingPrice) || 0.0
231
+ call_options = option_data.dig(:callExpDateMap) || {}
232
+ put_options = option_data.dig(:putExpDateMap) || {}
233
+
234
+ filter = SchwabMCP::OptionChainFilter.new(
235
+ expiration_date: expiration_date,
236
+ underlying_price: underlying_price,
237
+ expiration_type: expiration_type,
238
+ settlement_type: settlement_type,
239
+ option_root: option_root,
240
+ max_delta: max_delta,
241
+ max_spread: max_spread,
242
+ min_credit: min_credit,
243
+ min_open_interest: min_open_interest,
244
+ dist_from_strike: dist_from_strike,
245
+ quantity: quantity
246
+ )
247
+
248
+ call_spreads = filter.find_spreads(call_options, 'call')
249
+ put_spreads = filter.find_spreads(put_options, 'put')
250
+
251
+ return { status: 'not_found' } if call_spreads.empty? || put_spreads.empty?
252
+
253
+ best_combo = nil
254
+ best_ratio = 0
255
+
256
+ call_spreads.each do |call_spread|
257
+ put_spreads.each do |put_spread|
258
+ total_credit = call_spread[:credit] + put_spread[:credit]
259
+ next if total_credit < min_credit / 100.0
260
+
261
+ total_delta = call_spread[:delta].abs + put_spread[:delta].abs
262
+ ratio = total_credit / total_delta if total_delta > 0
263
+
264
+ if ratio > best_ratio
265
+ best_ratio = ratio
266
+ best_combo = {
267
+ type: 'iron_condor',
268
+ call_spread: call_spread,
269
+ put_spread: put_spread,
270
+ total_credit: total_credit,
271
+ total_delta: total_delta,
272
+ underlying_price: underlying_price
273
+ }
274
+ end
275
+ end
276
+ end
277
+
278
+ best_combo || { status: 'not_found' }
279
+ end
280
+
281
+ def self.find_spread(option_data, spread_type, underlying_symbol, expiration_date, expiration_type,
282
+ settlement_type, option_root, max_delta, max_spread, min_credit,
283
+ min_open_interest, dist_from_strike, quantity)
284
+
285
+ underlying_price = option_data.dig(:underlyingPrice) || 0.0
286
+ options_map = case spread_type
287
+ when 'call'
288
+ option_data.dig(:callExpDateMap) || {}
289
+ when 'put'
290
+ option_data.dig(:putExpDateMap) || {}
291
+ else
292
+ return { status: 'not_found' }
293
+ end
294
+
295
+ filter = SchwabMCP::OptionChainFilter.new(
296
+ expiration_date: expiration_date,
297
+ underlying_price: underlying_price,
298
+ expiration_type: expiration_type,
299
+ settlement_type: settlement_type,
300
+ option_root: option_root,
301
+ max_delta: max_delta,
302
+ max_spread: max_spread,
303
+ min_credit: min_credit,
304
+ min_open_interest: min_open_interest,
305
+ dist_from_strike: dist_from_strike,
306
+ quantity: quantity
307
+ )
308
+
309
+ spreads = filter.find_spreads(options_map, spread_type)
310
+
311
+ return { status: 'not_found' } if spreads.empty?
312
+
313
+ best_spread = spreads.max_by { |spread| spread[:credit] }
314
+ best_spread.merge(type: "#{spread_type}_spread", underlying_price: underlying_price)
315
+ end
316
+
317
+ def self.format_strategy_result(result, strategy_type)
318
+ case result[:type]
319
+ when 'iron_condor'
320
+ format_iron_condor(result)
321
+ when 'call_spread', 'put_spread'
322
+ format_spread(result, result[:type])
323
+ else
324
+ "**Found Strategy**: #{strategy_type.upcase}\n\n#{result.to_json}"
325
+ end
326
+ end
327
+
328
+ def self.format_iron_condor(result)
329
+ call_spread = result[:call_spread]
330
+ put_spread = result[:put_spread]
331
+
332
+ <<~TEXT
333
+ **IRON CONDOR FOUND**
334
+
335
+ **Underlying Price**: $#{result[:underlying_price].round(2)}
336
+ **Total Credit**: $#{(result[:total_credit] * 100).round(2)}
337
+
338
+ **Call Spread (Short)**:
339
+ - Short: #{call_spread[:short_option][:symbol]} $#{call_spread[:short_option][:strikePrice]} Call @ $#{call_spread[:short_option][:mark].round(2)}
340
+ - Long: #{call_spread[:long_option][:symbol]} $#{call_spread[:long_option][:strikePrice]} Call @ $#{call_spread[:long_option][:mark].round(2)}
341
+ - Credit: $#{(call_spread[:credit] * 100).round(2)}
342
+ - Width: $#{call_spread[:spread_width].round(2)}
343
+ - Delta: #{call_spread[:delta].round(2)}
344
+
345
+ **Put Spread (Short)**:
346
+ - Short: #{put_spread[:short_option][:symbol]} $#{put_spread[:short_option][:strikePrice]} Put @ $#{put_spread[:short_option][:mark].round(2)}
347
+ - Long: #{put_spread[:long_option][:symbol]} $#{put_spread[:long_option][:strikePrice]} Put @ $#{put_spread[:long_option][:mark].round(2)}
348
+ - Credit: $#{(put_spread[:credit] * 100).round(2)}
349
+ - Width: $#{put_spread[:spread_width].round(2)}
350
+ - Delta: #{put_spread[:delta].round(2)}
351
+ TEXT
352
+ end
353
+
354
+ def self.format_spread(result, spread_type)
355
+ short_opt = result[:short_option]
356
+ long_opt = result[:long_option]
357
+ option_type = spread_type == 'call_spread' ? 'Call' : 'Put'
358
+
359
+ <<~TEXT
360
+ **#{option_type.upcase} SPREAD FOUND**
361
+
362
+ **Underlying Price**: $#{result[:underlying_price].round(2)}
363
+ **Credit**: $#{(result[:credit] * 100).round(2)}
364
+ **Spread Width**: $#{result[:spread_width].round(2)}
365
+ **Delta**: #{result[:delta].round(4)}
366
+
367
+ **Short**: #{short_opt[:symbol]} $#{short_opt[:strikePrice]} #{option_type} @ $#{short_opt[:mark].round(2)}
368
+ - Delta: #{short_opt[:delta]&.round(4)}
369
+ - Open Interest: #{short_opt[:openInterest]}
370
+
371
+ **Long**: #{long_opt[:symbol]} $#{long_opt[:strikePrice]} #{option_type} @ $#{long_opt[:mark].round(2)}
372
+ - Delta: #{long_opt[:delta]&.round(4)}
373
+ - Open Interest: #{long_opt[:openInterest]}
374
+ TEXT
375
+ end
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,305 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require_relative "../loggable"
5
+ require_relative "../orders/order_factory"
6
+ require_relative "../redactor"
7
+
8
+ module SchwabMCP
9
+ module Tools
10
+ class PlaceOrderTool < MCP::Tool
11
+ extend Loggable
12
+ description "Place an options order (iron condor, call spread, put spread) for execution on the Schwab platform"
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
+ strategy_type: {
22
+ type: "string",
23
+ enum: ["ironcondor", "callspread", "putspread"],
24
+ description: "Type of options strategy to place"
25
+ },
26
+ price: {
27
+ type: "number",
28
+ description: "Net price for the order (credit for selling strategies, debit for buying strategies)"
29
+ },
30
+ quantity: {
31
+ type: "integer",
32
+ description: "Number of contracts (default: 1)",
33
+ default: 1
34
+ },
35
+ order_instruction: {
36
+ type: "string",
37
+ enum: ["open", "exit"],
38
+ description: "Whether to open a new position or exit an existing one (default: open)",
39
+ default: "open"
40
+ },
41
+ put_short_symbol: {
42
+ type: "string",
43
+ description: "Option symbol for the short put leg (required for iron condor)"
44
+ },
45
+ put_long_symbol: {
46
+ type: "string",
47
+ description: "Option symbol for the long put leg (required for iron condor)"
48
+ },
49
+ call_short_symbol: {
50
+ type: "string",
51
+ description: "Option symbol for the short call leg (required for iron condor)"
52
+ },
53
+ call_long_symbol: {
54
+ type: "string",
55
+ description: "Option symbol for the long call leg (required for iron condor)"
56
+ },
57
+ short_leg_symbol: {
58
+ type: "string",
59
+ description: "Option symbol for the short leg (required for call/put spreads)"
60
+ },
61
+ long_leg_symbol: {
62
+ type: "string",
63
+ description: "Option symbol for the long leg (required for call/put spreads)"
64
+ }
65
+ },
66
+ required: ["account_name", "strategy_type", "price"]
67
+ )
68
+
69
+ annotations(
70
+ title: "Place Options Order",
71
+ read_only_hint: false,
72
+ destructive_hint: true,
73
+ idempotent_hint: false
74
+ )
75
+
76
+ def self.call(server_context:, **params)
77
+ log_info("Placing #{params[:strategy_type]} order for account name: #{params[:account_name]}")
78
+
79
+ unless params[:account_name].end_with?('_ACCOUNT')
80
+ log_error("Invalid account name format: #{params[:account_name]}")
81
+ return MCP::Tool::Response.new([{
82
+ type: "text",
83
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
84
+ }])
85
+ end
86
+
87
+ begin
88
+ validate_strategy_params(params)
89
+ client = SchwabRb::Auth.init_client_easy(
90
+ ENV['SCHWAB_API_KEY'],
91
+ ENV['SCHWAB_APP_SECRET'],
92
+ ENV['SCHWAB_CALLBACK_URI'],
93
+ ENV['TOKEN_PATH']
94
+ )
95
+
96
+ unless client
97
+ log_error("Failed to initialize Schwab client")
98
+ return MCP::Tool::Response.new([{
99
+ type: "text",
100
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
101
+ }])
102
+ end
103
+
104
+ account_result = resolve_account_details(client, params[:account_name])
105
+ return account_result if account_result.is_a?(MCP::Tool::Response)
106
+
107
+ account_id, account_hash = account_result
108
+
109
+ order_builder = SchwabMCP::Orders::OrderFactory.build(
110
+ strategy_type: params[:strategy_type],
111
+ account_number: account_id,
112
+ price: params[:price],
113
+ quantity: params[:quantity] || 1,
114
+ order_instruction: (params[:order_instruction] || "open").to_sym,
115
+ # Iron Condor params
116
+ put_short_symbol: params[:put_short_symbol],
117
+ put_long_symbol: params[:put_long_symbol],
118
+ call_short_symbol: params[:call_short_symbol],
119
+ call_long_symbol: params[:call_long_symbol],
120
+ # Vertical spread params
121
+ short_leg_symbol: params[:short_leg_symbol],
122
+ long_leg_symbol: params[:long_leg_symbol]
123
+ )
124
+
125
+ log_debug("Making place order API request")
126
+ response = client.place_order(account_hash, order_builder)
127
+
128
+ if response && (200..299).include?(response.status)
129
+ log_info("Successfully placed #{params[:strategy_type]} order (HTTP #{response.status})")
130
+ formatted_response = format_place_order_response(response, params)
131
+ MCP::Tool::Response.new([{
132
+ type: "text",
133
+ text: formatted_response
134
+ }])
135
+ elsif response
136
+ log_error("Order placement failed with HTTP status #{response.status}")
137
+ error_details = extract_error_details(response)
138
+ MCP::Tool::Response.new([{
139
+ type: "text",
140
+ text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
141
+ }])
142
+ else
143
+ log_warn("Empty response from Schwab API for order placement")
144
+ MCP::Tool::Response.new([{
145
+ type: "text",
146
+ text: "**No Data**: Empty response from Schwab API for order placement"
147
+ }])
148
+ end
149
+
150
+ rescue => e
151
+ log_error("Error placing #{params[:strategy_type]} order: #{e.message}")
152
+ log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
153
+ MCP::Tool::Response.new([{
154
+ type: "text",
155
+ text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
156
+ }])
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def self.resolve_account_details(client, account_name)
163
+ account_id = ENV[account_name]
164
+ unless account_id
165
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
166
+ log_error("Account name '#{account_name}' not found in environment variables")
167
+ return MCP::Tool::Response.new([{
168
+ type: "text",
169
+ 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."
170
+ }])
171
+ end
172
+
173
+ log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
174
+ log_debug("Fetching account numbers mapping")
175
+
176
+ account_numbers_response = client.get_account_numbers
177
+
178
+ unless account_numbers_response&.body
179
+ log_error("Failed to retrieve account numbers")
180
+ return MCP::Tool::Response.new([{
181
+ type: "text",
182
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
183
+ }])
184
+ end
185
+
186
+ account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
187
+ log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
188
+
189
+ account_hash = nil
190
+ account_mappings.each do |mapping|
191
+ if mapping[:accountNumber] == account_id
192
+ account_hash = mapping[:hashValue]
193
+ break
194
+ end
195
+ end
196
+
197
+ unless account_hash
198
+ log_error("Account ID not found in available accounts")
199
+ return MCP::Tool::Response.new([{
200
+ type: "text",
201
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
202
+ }])
203
+ end
204
+
205
+ log_debug("Found account hash for account name: #{account_name}")
206
+ [account_id, account_hash]
207
+ end
208
+
209
+ def self.validate_strategy_params(params)
210
+ case params[:strategy_type]
211
+ when 'ironcondor'
212
+ required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
213
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
214
+ unless missing_fields.empty?
215
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
216
+ end
217
+ when 'callspread', 'putspread'
218
+ required_fields = [:short_leg_symbol, :long_leg_symbol]
219
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
220
+ unless missing_fields.empty?
221
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
222
+ end
223
+ else
224
+ raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
225
+ end
226
+ end
227
+
228
+ def self.format_place_order_response(response, params)
229
+ begin
230
+ strategy_summary = case params[:strategy_type]
231
+ when 'ironcondor'
232
+ "**Iron Condor Order Placed**\n" \
233
+ "- Put Short: #{params[:put_short_symbol]}\n" \
234
+ "- Put Long: #{params[:put_long_symbol]}\n" \
235
+ "- Call Short: #{params[:call_short_symbol]}\n" \
236
+ "- Call Long: #{params[:call_long_symbol]}\n"
237
+ when 'callspread', 'putspread'
238
+ "**#{params[:strategy_type].capitalize} Order Placed**\n" \
239
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
240
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
241
+ end
242
+
243
+ friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
244
+
245
+ order_details = "**Order Details:**\n" \
246
+ "- Strategy: #{params[:strategy_type]}\n" \
247
+ "- Action: #{params[:order_instruction] || 'open'}\n" \
248
+ "- Quantity: #{params[:quantity] || 1}\n" \
249
+ "- Price: $#{params[:price]}\n" \
250
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
251
+
252
+ order_id = extract_order_id_from_response(response)
253
+ order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
254
+
255
+ response_info = if response.body && !response.body.empty?
256
+ begin
257
+ parsed = JSON.parse(response.body)
258
+ redacted_data = Redactor.redact(parsed)
259
+ "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
260
+ rescue JSON::ParserError
261
+ "**Schwab API Response:**\n\n```\n#{response.body}\n```"
262
+ end
263
+ else
264
+ "**Status**: Order submitted successfully (HTTP #{response.status})"
265
+ end
266
+
267
+ "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
268
+ rescue => e
269
+ log_error("Error formatting response: #{e.message}")
270
+ "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
271
+ end
272
+ end
273
+
274
+ def self.extract_order_id_from_response(response)
275
+ # Schwab API typically returns the order ID in the Location header
276
+ # Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
277
+ location = response.headers['Location'] || response.headers['location']
278
+ return nil unless location
279
+
280
+ match = location.match(%r{/orders/(\d+)$})
281
+ match ? match[1] : nil
282
+ rescue => e
283
+ log_debug("Could not extract order ID from response: #{e.message}")
284
+ nil
285
+ end
286
+
287
+ def self.extract_error_details(response)
288
+ if response.body && !response.body.empty?
289
+ begin
290
+ parsed = JSON.parse(response.body)
291
+ redacted_data = Redactor.redact(parsed)
292
+ "**Error Details:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
293
+ rescue JSON::ParserError
294
+ "**Error Details:**\n\n```\n#{response.body}\n```"
295
+ end
296
+ else
297
+ "No additional error details provided."
298
+ end
299
+ rescue => e
300
+ log_debug("Error extracting error details: #{e.message}")
301
+ "Could not extract error details."
302
+ end
303
+ end
304
+ end
305
+ end