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.
- checksums.yaml +7 -0
- data/.copilotignore +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/Rakefile +12 -0
- data/exe/schwab_mcp +19 -0
- data/exe/schwab_token_refresh +38 -0
- data/exe/schwab_token_reset +49 -0
- data/lib/schwab_mcp/loggable.rb +31 -0
- data/lib/schwab_mcp/logger.rb +62 -0
- data/lib/schwab_mcp/option_chain_filter.rb +213 -0
- data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
- data/lib/schwab_mcp/orders/order_factory.rb +40 -0
- data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
- data/lib/schwab_mcp/redactor.rb +210 -0
- data/lib/schwab_mcp/resources/.keep +0 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
- data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
- data/lib/schwab_mcp/tools/help_tool.rb +406 -0
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
- data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
- data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
- data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
- data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
- data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
- data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
- data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
- data/lib/schwab_mcp/version.rb +5 -0
- data/lib/schwab_mcp.rb +107 -0
- data/sig/schwab_mcp.rbs +4 -0
- data/start_mcp_server.sh +4 -0
- 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
|