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.
- checksums.yaml +4 -4
- data/.claude/settings.json +14 -0
- data/CLAUDE.md +124 -0
- data/debug_env.rb +46 -0
- data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
- data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
- data/exe/schwab_mcp +14 -3
- data/exe/schwab_token_refresh +10 -9
- data/lib/schwab_mcp/redactor.rb +4 -0
- data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -50
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
- data/lib/schwab_mcp/tools/get_order_tool.rb +51 -108
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
- data/lib/schwab_mcp/tools/help_tool.rb +1 -22
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +35 -63
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -72
- data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +18 -31
- data/lib/schwab_mcp/tools/option_chain_tool.rb +130 -82
- data/lib/schwab_mcp/tools/place_order_tool.rb +105 -117
- data/lib/schwab_mcp/tools/preview_order_tool.rb +100 -48
- data/lib/schwab_mcp/tools/quote_tool.rb +33 -26
- data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
- data/lib/schwab_mcp/tools/replace_order_tool.rb +104 -116
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -72
- data/lib/schwab_mcp/version.rb +1 -1
- data/lib/schwab_mcp.rb +1 -2
- data/orders_example.json +7084 -0
- data/spx_option_chain.json +25073 -0
- data/test_mcp.rb +16 -0
- data/test_server.rb +23 -0
- data/trading_brokerage_account_details.json +89 -0
- data/transactions_example.json +488 -0
- metadata +17 -7
- data/lib/schwab_mcp/option_chain_filter.rb +0 -213
- data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
- data/lib/schwab_mcp/orders/order_factory.rb +0 -40
- data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
- 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 "../
|
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: [
|
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: [
|
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: [
|
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: [
|
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: [
|
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: [
|
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(
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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 =
|
137
|
-
|
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
|
-
|
172
|
+
option_chain = client.get_option_chain(symbol.upcase, return_data_objects: true, **params)
|
179
173
|
|
180
|
-
if
|
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
|
-
|
189
|
+
filtered_calls = filter.select(option_chain.call_opts)
|
190
|
+
filtered_puts = filter.select(option_chain.put_opts)
|
198
191
|
|
199
|
-
|
200
|
-
|
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
|
-
|
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
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
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
|
-
|
232
|
-
|
233
|
-
|
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
|
-
|
240
|
-
|
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
|
-
|
249
|
-
|
250
|
-
|
224
|
+
type: "text",
|
225
|
+
text: error_text
|
226
|
+
}])
|
251
227
|
end
|
252
228
|
end
|
253
229
|
|
254
|
-
|
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
|
-
|
257
|
-
|
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
|
-
|
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
|
-
|
262
|
-
|
263
|
-
|
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
|
-
|
266
|
-
|
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
|
-
|
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
|