schwab_mcp 0.1.0 → 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.json +14 -0
  3. data/CLAUDE.md +124 -0
  4. data/debug_env.rb +46 -0
  5. data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
  6. data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
  7. data/exe/schwab_mcp +14 -3
  8. data/exe/schwab_token_refresh +10 -9
  9. data/lib/schwab_mcp/redactor.rb +4 -0
  10. data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
  11. data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -50
  12. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
  13. data/lib/schwab_mcp/tools/get_order_tool.rb +51 -108
  14. data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
  15. data/lib/schwab_mcp/tools/help_tool.rb +1 -22
  16. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +35 -63
  17. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -72
  18. data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
  19. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +18 -31
  20. data/lib/schwab_mcp/tools/option_chain_tool.rb +130 -82
  21. data/lib/schwab_mcp/tools/place_order_tool.rb +105 -117
  22. data/lib/schwab_mcp/tools/preview_order_tool.rb +100 -48
  23. data/lib/schwab_mcp/tools/quote_tool.rb +33 -26
  24. data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
  25. data/lib/schwab_mcp/tools/replace_order_tool.rb +104 -116
  26. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -72
  27. data/lib/schwab_mcp/version.rb +1 -1
  28. data/lib/schwab_mcp.rb +1 -2
  29. data/orders_example.json +7084 -0
  30. data/spx_option_chain.json +25073 -0
  31. data/test_mcp.rb +16 -0
  32. data/test_server.rb +23 -0
  33. data/trading_brokerage_account_details.json +89 -0
  34. data/transactions_example.json +488 -0
  35. metadata +17 -7
  36. data/lib/schwab_mcp/option_chain_filter.rb +0 -213
  37. data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
  38. data/lib/schwab_mcp/orders/order_factory.rb +0 -40
  39. data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
  40. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mcp"
2
4
  require "schwab_rb"
3
- require "json"
4
5
  require "date"
5
6
  require_relative "../loggable"
6
- require_relative "../option_chain_filter"
7
+ require_relative "../schwab_client_factory"
7
8
 
8
9
  module SchwabMCP
9
10
  module Tools
@@ -21,7 +22,7 @@ module SchwabMCP
21
22
  contract_type: {
22
23
  type: "string",
23
24
  description: "Type of contracts to return in the chain",
24
- enum: ["CALL", "PUT", "ALL"]
25
+ enum: %w[CALL PUT ALL]
25
26
  },
26
27
  strike_count: {
27
28
  type: "integer",
@@ -35,22 +36,23 @@ module SchwabMCP
35
36
  strategy: {
36
37
  type: "string",
37
38
  description: "Strategy type for the option chain",
38
- enum: ["SINGLE", "ANALYTICAL", "COVERED", "VERTICAL", "CALENDAR", "STRANGLE", "STRADDLE", "BUTTERFLY", "CONDOR", "DIAGONAL", "COLLAR", "ROLL"]
39
+ enum: %w[SINGLE ANALYTICAL COVERED VERTICAL CALENDAR STRANGLE STRADDLE BUTTERFLY
40
+ CONDOR DIAGONAL COLLAR ROLL]
39
41
  },
40
42
  strike_range: {
41
43
  type: "string",
42
44
  description: "Range of strikes to include",
43
- enum: ["ITM", "NTM", "OTM", "SAK", "SBK", "SNK", "ALL"]
45
+ enum: %w[ITM NTM OTM SAK SBK SNK ALL]
44
46
  },
45
47
  option_type: {
46
48
  type: "string",
47
49
  description: "Type of options to include in the chain",
48
- enum: ["S", "NS", "ALL"]
50
+ enum: %w[S NS ALL]
49
51
  },
50
52
  exp_month: {
51
53
  type: "string",
52
54
  description: "Filter options by expiration month",
53
- enum: ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "ALL"]
55
+ enum: %w[JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC ALL]
54
56
  },
55
57
  interval: {
56
58
  type: "number",
@@ -87,7 +89,7 @@ module SchwabMCP
87
89
  entitlement: {
88
90
  type: "string",
89
91
  description: "Client entitlement",
90
- enum: ["PP", "NP", "PN"]
92
+ enum: %w[PP NP PN]
91
93
  },
92
94
  max_delta: {
93
95
  type: "number",
@@ -124,29 +126,21 @@ module SchwabMCP
124
126
  idempotent_hint: true
125
127
  )
126
128
 
127
- def self.call(symbol:, contract_type: nil, strike_count: nil, include_underlying_quote: nil,
128
- strategy: nil, strike_range: nil, option_type: nil, exp_month: nil,
129
- interval: nil, strike: nil, from_date: nil, to_date: nil, volatility: nil,
130
- underlying_price: nil, interest_rate: nil, days_to_expiration: nil,
131
- entitlement: nil, max_delta: nil, min_delta: nil, max_strike: nil,
132
- min_strike: nil, expiration_date: nil, server_context:)
129
+ def self.call(
130
+ symbol:, server_context:, contract_type: nil, strike_count: nil,
131
+ include_underlying_quote: nil,
132
+ strategy: nil, strike_range: nil,
133
+ option_type: nil, exp_month: nil,
134
+ interval: nil, strike: nil, from_date: nil, to_date: nil, volatility: nil,
135
+ underlying_price: nil, interest_rate: nil, days_to_expiration: nil,
136
+ entitlement: nil, max_delta: nil, min_delta: nil, max_strike: nil,
137
+ min_strike: nil, expiration_date: nil
138
+ )
133
139
  log_info("Getting option chain for symbol: #{symbol}")
134
140
 
135
141
  begin
136
- client = SchwabRb::Auth.init_client_easy(
137
- ENV['SCHWAB_API_KEY'],
138
- ENV['SCHWAB_APP_SECRET'],
139
- ENV['SCHWAB_CALLBACK_URI'],
140
- ENV['TOKEN_PATH']
141
- )
142
-
143
- unless client
144
- log_error("Failed to initialize Schwab client")
145
- return MCP::Tool::Response.new([{
146
- type: "text",
147
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
148
- }])
149
- end
142
+ client = SchwabClientFactory.create_client
143
+ return SchwabClientFactory.client_error_response unless client
150
144
 
151
145
  params = {}
152
146
  params[:contract_type] = contract_type if contract_type
@@ -175,15 +169,13 @@ module SchwabMCP
175
169
  params[:entitlement] = entitlement if entitlement
176
170
 
177
171
  log_debug("Making API request for option chain with params: #{params}")
178
- response = client.get_option_chain(symbol.upcase, **params)
172
+ option_chain = client.get_option_chain(symbol.upcase, return_data_objects: true, **params)
179
173
 
180
- if response&.body
174
+ if option_chain
181
175
  log_info("Successfully retrieved option chain for #{symbol}")
182
176
 
183
177
  if max_delta || min_delta || max_strike || min_strike
184
178
  begin
185
- parsed_response = JSON.parse(response.body, symbolize_names: true)
186
-
187
179
  log_debug("Applying option chain filtering")
188
180
 
189
181
  filter = SchwabMCP::OptionChainFilter.new(
@@ -194,80 +186,136 @@ module SchwabMCP
194
186
  min_strike: min_strike
195
187
  )
196
188
 
197
- filtered_response = parsed_response.dup
189
+ filtered_calls = filter.select(option_chain.call_opts)
190
+ filtered_puts = filter.select(option_chain.put_opts)
198
191
 
199
- if parsed_response[:callExpDateMap]
200
- filtered_calls = filter.select(parsed_response[:callExpDateMap])
201
- log_debug("Filtered #{filtered_calls.size} call options")
192
+ log_debug("Filtered #{filtered_calls.size} call options")
193
+ log_debug("Filtered #{filtered_puts.size} put options")
202
194
 
203
- filtered_response[:callExpDateMap] = reconstruct_exp_date_map(
204
- filtered_calls, expiration_date
205
- )
206
- end
195
+ filtered_option_chain = create_filtered_option_chain(option_chain, filtered_calls, filtered_puts)
207
196
 
208
- if parsed_response[:putExpDateMap]
209
- filtered_puts = filter.select(parsed_response[:putExpDateMap])
210
- log_debug("Filtered #{filtered_puts.size} put options")
211
-
212
- filtered_response[:putExpDateMap] = reconstruct_exp_date_map(
213
- filtered_puts, expiration_date)
214
- end
215
-
216
- File.open("filtered_option_chain_#{symbol}_#{expiration_date}.json", "w") do |f|
217
- f.write(JSON.pretty_generate(filtered_response))
218
- end
219
-
220
- return MCP::Tool::Response.new([{
221
- type: "text",
222
- text: "#{JSON.pretty_generate(filtered_response)}\n"
223
- }])
224
- rescue JSON::ParserError => e
225
- log_error("Failed to parse response for filtering: #{e.message}")
226
- rescue => e
197
+ MCP::Tool::Response.new([{
198
+ type: "text",
199
+ text: format_option_chain_response(filtered_option_chain)
200
+ }])
201
+ rescue StandardError => e
227
202
  log_error("Error applying option chain filter: #{e.message}")
228
203
  end
229
204
  else
230
205
  log_debug("No filtering applied, returning full response")
231
- return MCP::Tool::Response.new([{
232
- type: "text",
233
- text: "#{JSON.pretty_generate(response.body)}\n"
234
- }])
206
+ MCP::Tool::Response.new([{
207
+ type: "text",
208
+ text: format_option_chain_response(option_chain)
209
+ }])
235
210
  end
236
211
  else
237
212
  log_warn("Empty response from Schwab API for option chain: #{symbol}")
238
213
  MCP::Tool::Response.new([{
239
- type: "text",
240
- text: "**No Data**: Empty response from Schwab API for option chain: #{symbol}"
241
- }])
214
+ type: "text",
215
+ text: "**No Data**: Empty response from Schwab API for option chain: #{symbol}"
216
+ }])
242
217
  end
243
-
244
- rescue => e
218
+ rescue StandardError => e
245
219
  log_error("Error retrieving option chain for #{symbol}: #{e.message}")
246
220
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
221
+ error_text = "**Error** retrieving option chain for #{symbol}: #{e.message}\n\n"
222
+ error_text += e.backtrace.first(3).join('\n')
247
223
  MCP::Tool::Response.new([{
248
- type: "text",
249
- text: "**Error** retrieving option chain for #{symbol}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
250
- }])
224
+ type: "text",
225
+ text: error_text
226
+ }])
251
227
  end
252
228
  end
253
229
 
254
- private
230
+ private_class_method def self.format_option_chain_response(data)
231
+ return data.to_s if data.is_a?(Hash)
232
+
233
+ output = []
234
+ output << "**Option Chain for #{data.symbol}**"
235
+ output << "Status: #{data.status}" if data.respond_to?(:status)
236
+ output << "Underlying Price: $#{data.underlying_price}" if data.respond_to?(:underlying_price)
237
+ output << ""
238
+
239
+ strikes_hash = {}
240
+
241
+ data.call_opts.each do |call_opt|
242
+ strike = call_opt.strike
243
+ strikes_hash[strike] ||= { call: nil, put: nil }
244
+ strikes_hash[strike][:call] = call_opt
245
+ end
246
+
247
+ data.put_opts.each do |put_opt|
248
+ strike = put_opt.strike
249
+ strikes_hash[strike] ||= { call: nil, put: nil }
250
+ strikes_hash[strike][:put] = put_opt
251
+ end
252
+
253
+ output << "| Call Symbol | Call Mark | Call Ask | Call Bid | Call Delta | Call Open Interest |" \
254
+ " Strike | Put Symbol | Put Mark | Put Ask | Put Bid | Put Delta | Put Open Interest |"
255
+ output << "|-------------|-----------|----------|----------|------------|------------|" \
256
+ "--------|------------|----------|---------|---------|-----------|-----------|"
255
257
 
256
- def self.reconstruct_exp_date_map(filtered_options, target_expiration_date)
257
- return {} if filtered_options.empty?
258
+ # Sort strikes and create table rows
259
+ strikes_hash.keys.sort.each do |strike|
260
+ call_opt = strikes_hash[strike][:call]
261
+ put_opt = strikes_hash[strike][:put]
258
262
 
259
- grouped = {}
263
+ call_symbol = call_opt ? call_opt.symbol : ""
264
+ call_mark = call_opt ? format_price(call_opt.mark) : ""
265
+ call_ask = call_opt ? format_price(call_opt.ask) : ""
266
+ call_bid = call_opt ? format_price(call_opt.bid) : ""
267
+ call_delta = call_opt ? format_greek(call_opt.delta) : ""
268
+ call_open_interest = call_opt ? format_count(call_opt.open_interest) : ""
260
269
 
261
- filtered_options.each do |option|
262
- exp_date_key = "#{target_expiration_date}:#{option[:daysToExpiration] || 0}"
263
- strike_key = "#{option[:strikePrice]}"
270
+ put_symbol = put_opt ? put_opt.symbol : ""
271
+ put_mark = put_opt ? format_price(put_opt.mark) : ""
272
+ put_ask = put_opt ? format_price(put_opt.ask) : ""
273
+ put_bid = put_opt ? format_price(put_opt.bid) : ""
274
+ put_delta = put_opt ? format_greek(put_opt.delta) : ""
275
+ put_open_interest = put_opt ? format_count(put_opt.open_interest) : ""
264
276
 
265
- grouped[exp_date_key] ||= {}
266
- grouped[exp_date_key][strike_key] ||= []
267
- grouped[exp_date_key][strike_key] << option
277
+ output << "| #{call_symbol} | #{call_mark} | #{call_ask} | #{call_bid} | #{call_delta} | #{call_open_interest} |" \
278
+ " #{strike} | #{put_symbol} | #{put_mark} | #{put_ask} | #{put_bid} | #{put_delta} | #{put_open_interest} |"
268
279
  end
269
280
 
270
- grouped
281
+ output.join("\n")
282
+ end
283
+
284
+ private_class_method def self.format_price(price)
285
+ return "" if price.nil?
286
+
287
+ price.zero? ? "0.00" : format("%.2f", price)
288
+ end
289
+
290
+ private_class_method def self.format_greek(greek)
291
+ return "" if greek.nil?
292
+
293
+ greek.zero? ? "0.00" : format("%.3f", greek)
294
+ end
295
+
296
+ private_class_method def self.format_count(count)
297
+ return "" if count.nil?
298
+
299
+ count.zero? ? "0" : count.to_s
300
+ end
301
+
302
+
303
+ private_class_method def self.create_filtered_option_chain(original_chain, filtered_calls, filtered_puts)
304
+ # Create a simple object that mimics the original data object interface
305
+ FilteredOptionChain.new(
306
+ symbol: original_chain.symbol,
307
+ status: original_chain.status,
308
+ underlying_price: original_chain.underlying_price,
309
+ call_opts: filtered_calls,
310
+ put_opts: filtered_puts
311
+ )
312
+ end
313
+
314
+ # Simple struct to hold filtered option chain data
315
+ FilteredOptionChain = Struct.new(:symbol, :status, :underlying_price, :call_opts, :put_opts) do
316
+ def respond_to?(method_name)
317
+ super || members.include?(method_name)
318
+ end
271
319
  end
272
320
  end
273
321
  end