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,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