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,378 +0,0 @@
|
|
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
|