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,125 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
|
6
|
+
module SchwabMCP
|
7
|
+
module Tools
|
8
|
+
class ListMoversTool < MCP::Tool
|
9
|
+
extend Loggable
|
10
|
+
description "Get a list of the top ten movers for a given index using Schwab API"
|
11
|
+
|
12
|
+
input_schema(
|
13
|
+
properties: {
|
14
|
+
index: {
|
15
|
+
type: "string",
|
16
|
+
description: "Category of mover",
|
17
|
+
enum: ["$DJI", "$COMPX", "$SPX", "NYSE", "NASDAQ", "OTCBB", "INDEX_ALL", "EQUITY_ALL", "OPTION_ALL", "OPTION_PUT", "OPTION_CALL"]
|
18
|
+
},
|
19
|
+
sort_order: {
|
20
|
+
type: "string",
|
21
|
+
description: "Order in which to return values (optional)",
|
22
|
+
enum: ["VOLUME", "TRADES", "PERCENT_CHANGE_UP", "PERCENT_CHANGE_DOWN"]
|
23
|
+
},
|
24
|
+
frequency: {
|
25
|
+
type: "integer",
|
26
|
+
description: "Only return movers that saw this magnitude or greater (optional)",
|
27
|
+
enum: [0, 1, 5, 10, 30, 60]
|
28
|
+
}
|
29
|
+
},
|
30
|
+
required: ["index"]
|
31
|
+
)
|
32
|
+
|
33
|
+
annotations(
|
34
|
+
title: "List Market Movers",
|
35
|
+
read_only_hint: true,
|
36
|
+
destructive_hint: false,
|
37
|
+
idempotent_hint: true
|
38
|
+
)
|
39
|
+
|
40
|
+
def self.call(index:, sort_order: nil, frequency: nil, server_context:)
|
41
|
+
log_info("Getting movers for index: #{index}, sort_order: #{sort_order}, frequency: #{frequency}")
|
42
|
+
|
43
|
+
begin
|
44
|
+
client = SchwabRb::Auth.init_client_easy(
|
45
|
+
ENV['SCHWAB_API_KEY'],
|
46
|
+
ENV['SCHWAB_APP_SECRET'],
|
47
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
48
|
+
ENV['TOKEN_PATH']
|
49
|
+
)
|
50
|
+
|
51
|
+
unless client
|
52
|
+
log_error("Failed to initialize Schwab client")
|
53
|
+
return MCP::Tool::Response.new([{
|
54
|
+
type: "text",
|
55
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
56
|
+
}])
|
57
|
+
end
|
58
|
+
|
59
|
+
log_debug("Making API request for movers - index: #{index}, sort_order: #{sort_order}, frequency: #{frequency}")
|
60
|
+
|
61
|
+
response = client.get_movers(
|
62
|
+
index,
|
63
|
+
sort_order: sort_order,
|
64
|
+
frequency: frequency
|
65
|
+
)
|
66
|
+
|
67
|
+
if response&.body
|
68
|
+
log_info("Successfully retrieved movers for index #{index}")
|
69
|
+
parsed_body = JSON.parse(response.body)
|
70
|
+
|
71
|
+
formatted_output = format_movers_response(parsed_body, index, sort_order, frequency)
|
72
|
+
|
73
|
+
MCP::Tool::Response.new([{
|
74
|
+
type: "text",
|
75
|
+
text: formatted_output
|
76
|
+
}])
|
77
|
+
else
|
78
|
+
log_warn("Empty response from Schwab API for movers")
|
79
|
+
MCP::Tool::Response.new([{
|
80
|
+
type: "text",
|
81
|
+
text: "**No Data**: Empty response from Schwab API for movers"
|
82
|
+
}])
|
83
|
+
end
|
84
|
+
|
85
|
+
rescue => e
|
86
|
+
log_error("Error retrieving movers: #{e.message}")
|
87
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
88
|
+
MCP::Tool::Response.new([{
|
89
|
+
type: "text",
|
90
|
+
text: "**Error** retrieving movers: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
91
|
+
}])
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def self.format_movers_response(data, index, sort_order, frequency)
|
98
|
+
header = "**Market Movers for #{index}**"
|
99
|
+
header += " (sorted by #{sort_order})" if sort_order
|
100
|
+
header += " (frequency filter: #{frequency})" if frequency
|
101
|
+
header += "\n\n"
|
102
|
+
|
103
|
+
if data.is_a?(Array) && data.any?
|
104
|
+
movers_list = data.map.with_index(1) do |mover, i|
|
105
|
+
symbol = mover['symbol'] || 'N/A'
|
106
|
+
description = mover['description'] || 'N/A'
|
107
|
+
change = mover['change'] || 0
|
108
|
+
percent_change = mover['percentChange'] || 0
|
109
|
+
volume = mover['totalVolume'] || 0
|
110
|
+
last_price = mover['last'] || 0
|
111
|
+
|
112
|
+
"#{i}. **#{symbol}** - #{description}\n" \
|
113
|
+
" Last: $#{last_price}\n" \
|
114
|
+
" Change: #{change >= 0 ? '+' : ''}#{change} (#{percent_change >= 0 ? '+' : ''}#{percent_change}%)\n" \
|
115
|
+
" Volume: #{volume.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
116
|
+
end.join("\n\n")
|
117
|
+
|
118
|
+
"#{header}#{movers_list}"
|
119
|
+
else
|
120
|
+
"#{header}No movers data available.\n\n**Raw Response:**\n```json\n#{JSON.pretty_generate(data)}\n```"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
require_relative "../redactor"
|
6
|
+
|
7
|
+
module SchwabMCP
|
8
|
+
module Tools
|
9
|
+
class ListSchwabAccountsTool < MCP::Tool
|
10
|
+
extend Loggable
|
11
|
+
description "List all configured Schwab accounts with their friendly names and basic info"
|
12
|
+
|
13
|
+
input_schema(
|
14
|
+
properties: {},
|
15
|
+
required: []
|
16
|
+
)
|
17
|
+
|
18
|
+
annotations(
|
19
|
+
title: "List Schwab Accounts",
|
20
|
+
read_only_hint: true,
|
21
|
+
destructive_hint: false,
|
22
|
+
idempotent_hint: true
|
23
|
+
)
|
24
|
+
|
25
|
+
def self.call(server_context:)
|
26
|
+
log_info("Listing all configured Schwab accounts")
|
27
|
+
|
28
|
+
begin
|
29
|
+
client = SchwabRb::Auth.init_client_easy(
|
30
|
+
ENV['SCHWAB_API_KEY'],
|
31
|
+
ENV['SCHWAB_APP_SECRET'],
|
32
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
33
|
+
ENV['TOKEN_PATH']
|
34
|
+
)
|
35
|
+
|
36
|
+
unless client
|
37
|
+
log_error("Failed to initialize Schwab client")
|
38
|
+
return MCP::Tool::Response.new([{
|
39
|
+
type: "text",
|
40
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
41
|
+
}])
|
42
|
+
end
|
43
|
+
|
44
|
+
log_debug("Fetching account numbers from Schwab API")
|
45
|
+
account_numbers_response = client.get_account_numbers
|
46
|
+
|
47
|
+
unless account_numbers_response&.body
|
48
|
+
log_error("Failed to retrieve account numbers")
|
49
|
+
return MCP::Tool::Response.new([{
|
50
|
+
type: "text",
|
51
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
52
|
+
}])
|
53
|
+
end
|
54
|
+
|
55
|
+
account_mappings = JSON.parse(account_numbers_response.body)
|
56
|
+
log_debug("Retrieved #{account_mappings.length} accounts from Schwab API")
|
57
|
+
|
58
|
+
configured_accounts = find_configured_accounts(account_mappings)
|
59
|
+
|
60
|
+
if configured_accounts.empty?
|
61
|
+
return MCP::Tool::Response.new([{
|
62
|
+
type: "text",
|
63
|
+
text: "**No Configured Accounts Found**\n\nNo environment variables found ending with '_ACCOUNT'.\n\nTo configure accounts, set environment variables like:\n- TRADING_BROKERAGE_ACCOUNT=123456789\n- RETIREMENT_IRA_ACCOUNT=987654321\n- INCOME_BROKERAGE_ACCOUNT=555666777"
|
64
|
+
}])
|
65
|
+
end
|
66
|
+
|
67
|
+
formatted_response = format_accounts_list(configured_accounts, account_mappings)
|
68
|
+
|
69
|
+
MCP::Tool::Response.new([{
|
70
|
+
type: "text",
|
71
|
+
text: formatted_response
|
72
|
+
}])
|
73
|
+
|
74
|
+
rescue JSON::ParserError => e
|
75
|
+
log_error("JSON parsing error: #{e.message}")
|
76
|
+
MCP::Tool::Response.new([{
|
77
|
+
type: "text",
|
78
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
79
|
+
}])
|
80
|
+
rescue => e
|
81
|
+
log_error("Error listing accounts: #{e.message}")
|
82
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
83
|
+
MCP::Tool::Response.new([{
|
84
|
+
type: "text",
|
85
|
+
text: "**Error** listing accounts: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
86
|
+
}])
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def self.find_configured_accounts(account_mappings)
|
93
|
+
# Get all account IDs from Schwab API
|
94
|
+
schwab_account_ids = account_mappings.map { |mapping| mapping["accountNumber"] }
|
95
|
+
|
96
|
+
# Find environment variables ending with "_ACCOUNT"
|
97
|
+
configured = []
|
98
|
+
ENV.each do |key, value|
|
99
|
+
next unless key.end_with?('_ACCOUNT')
|
100
|
+
|
101
|
+
if schwab_account_ids.include?(value)
|
102
|
+
configured << {
|
103
|
+
name: key,
|
104
|
+
friendly_name: friendly_name_from_env_key(key),
|
105
|
+
account_id: value,
|
106
|
+
mapping: account_mappings.find { |m| m["accountNumber"] == value }
|
107
|
+
}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
configured.sort_by { |account| account[:name] }
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.friendly_name_from_env_key(env_key)
|
115
|
+
# Convert "TRADING_BROKERAGE_ACCOUNT" to "Trading Brokerage"
|
116
|
+
env_key.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.format_accounts_list(configured_accounts, all_mappings)
|
120
|
+
response = "**Configured Schwab Accounts:**\n\n"
|
121
|
+
|
122
|
+
configured_accounts.each_with_index do |account, index|
|
123
|
+
response += "#{index + 1}. **#{account[:friendly_name]}** (`#{account[:name]}`)\n"
|
124
|
+
response += " - Account ID: #{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}\n"
|
125
|
+
response += " - Status: ✅ Configured\n\n"
|
126
|
+
end
|
127
|
+
|
128
|
+
# Show unconfigured accounts (if any)
|
129
|
+
unconfigured = all_mappings.reject do |mapping|
|
130
|
+
configured_accounts.any? { |config| config[:account_id] == mapping["accountNumber"] }
|
131
|
+
end
|
132
|
+
|
133
|
+
if unconfigured.any?
|
134
|
+
response += "**Unconfigured Accounts Available:**\n\n"
|
135
|
+
unconfigured.each_with_index do |mapping, index|
|
136
|
+
response += "#{index + 1}. Account ID: #{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}\n"
|
137
|
+
response += " - To configure: Set `YOUR_NAME_ACCOUNT=#{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}` in your .env file\n\n"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
response += "**Usage:**\n"
|
142
|
+
response += "To get account information, use the `schwab_account` tool with one of these account names:\n"
|
143
|
+
configured_accounts.each do |account|
|
144
|
+
response += "- `#{account[:name]}`\n"
|
145
|
+
end
|
146
|
+
|
147
|
+
if configured_accounts.any?
|
148
|
+
response += "\n**Example:**\n"
|
149
|
+
first_account = configured_accounts.first
|
150
|
+
response += "```\n"
|
151
|
+
response += "Tool: schwab_account\n"
|
152
|
+
response += "Parameters: {\n"
|
153
|
+
response += " \"account_name\": \"#{first_account[:name]}\"\n"
|
154
|
+
response += "}\n"
|
155
|
+
response += "```"
|
156
|
+
end
|
157
|
+
|
158
|
+
response
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,274 @@
|
|
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 OptionChainTool < MCP::Tool
|
11
|
+
extend Loggable
|
12
|
+
description "Get option chain data for an optionable symbol using Schwab API"
|
13
|
+
|
14
|
+
input_schema(
|
15
|
+
properties: {
|
16
|
+
symbol: {
|
17
|
+
type: "string",
|
18
|
+
description: "Instrument symbol (e.g., 'AAPL', 'TSLA')",
|
19
|
+
pattern: "^[A-Za-z]{1,5}$"
|
20
|
+
},
|
21
|
+
contract_type: {
|
22
|
+
type: "string",
|
23
|
+
description: "Type of contracts to return in the chain",
|
24
|
+
enum: ["CALL", "PUT", "ALL"]
|
25
|
+
},
|
26
|
+
strike_count: {
|
27
|
+
type: "integer",
|
28
|
+
description: "Number of strikes above and below the ATM price",
|
29
|
+
minimum: 1
|
30
|
+
},
|
31
|
+
include_underlying_quote: {
|
32
|
+
type: "boolean",
|
33
|
+
description: "Include a quote for the underlying instrument"
|
34
|
+
},
|
35
|
+
strategy: {
|
36
|
+
type: "string",
|
37
|
+
description: "Strategy type for the option chain",
|
38
|
+
enum: ["SINGLE", "ANALYTICAL", "COVERED", "VERTICAL", "CALENDAR", "STRANGLE", "STRADDLE", "BUTTERFLY", "CONDOR", "DIAGONAL", "COLLAR", "ROLL"]
|
39
|
+
},
|
40
|
+
strike_range: {
|
41
|
+
type: "string",
|
42
|
+
description: "Range of strikes to include",
|
43
|
+
enum: ["ITM", "NTM", "OTM", "SAK", "SBK", "SNK", "ALL"]
|
44
|
+
},
|
45
|
+
option_type: {
|
46
|
+
type: "string",
|
47
|
+
description: "Type of options to include in the chain",
|
48
|
+
enum: ["S", "NS", "ALL"]
|
49
|
+
},
|
50
|
+
exp_month: {
|
51
|
+
type: "string",
|
52
|
+
description: "Filter options by expiration month",
|
53
|
+
enum: ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "ALL"]
|
54
|
+
},
|
55
|
+
interval: {
|
56
|
+
type: "number",
|
57
|
+
description: "Strike interval for spread strategy chains"
|
58
|
+
},
|
59
|
+
strike: {
|
60
|
+
type: "number",
|
61
|
+
description: "Specific strike price for the option chain"
|
62
|
+
},
|
63
|
+
from_date: {
|
64
|
+
type: "string",
|
65
|
+
description: "Filter expirations after this date (YYYY-MM-DD format)"
|
66
|
+
},
|
67
|
+
to_date: {
|
68
|
+
type: "string",
|
69
|
+
description: "Filter expirations before this date (YYYY-MM-DD format)"
|
70
|
+
},
|
71
|
+
volatility: {
|
72
|
+
type: "number",
|
73
|
+
description: "Volatility for analytical calculations"
|
74
|
+
},
|
75
|
+
underlying_price: {
|
76
|
+
type: "number",
|
77
|
+
description: "Underlying price for analytical calculations"
|
78
|
+
},
|
79
|
+
interest_rate: {
|
80
|
+
type: "number",
|
81
|
+
description: "Interest rate for analytical calculations"
|
82
|
+
},
|
83
|
+
days_to_expiration: {
|
84
|
+
type: "integer",
|
85
|
+
description: "Days to expiration for analytical calculations"
|
86
|
+
},
|
87
|
+
entitlement: {
|
88
|
+
type: "string",
|
89
|
+
description: "Client entitlement",
|
90
|
+
enum: ["PP", "NP", "PN"]
|
91
|
+
},
|
92
|
+
max_delta: {
|
93
|
+
type: "number",
|
94
|
+
description: "Maximum delta value for option filtering",
|
95
|
+
minimum: 0,
|
96
|
+
maximum: 1
|
97
|
+
},
|
98
|
+
min_delta: {
|
99
|
+
type: "number",
|
100
|
+
description: "Minimum delta value for option filtering",
|
101
|
+
minimum: 0,
|
102
|
+
maximum: 1
|
103
|
+
},
|
104
|
+
max_strike: {
|
105
|
+
type: "number",
|
106
|
+
description: "Maximum strike price for option filtering"
|
107
|
+
},
|
108
|
+
min_strike: {
|
109
|
+
type: "number",
|
110
|
+
description: "Minimum strike price for option filtering"
|
111
|
+
},
|
112
|
+
expiration_date: {
|
113
|
+
type: "string",
|
114
|
+
description: "Filter options by specific expiration date (YYYY-MM-DD format)"
|
115
|
+
}
|
116
|
+
},
|
117
|
+
required: ["symbol"]
|
118
|
+
)
|
119
|
+
|
120
|
+
annotations(
|
121
|
+
title: "Get Option Chain Data",
|
122
|
+
read_only_hint: true,
|
123
|
+
destructive_hint: false,
|
124
|
+
idempotent_hint: true
|
125
|
+
)
|
126
|
+
|
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:)
|
133
|
+
log_info("Getting option chain for symbol: #{symbol}")
|
134
|
+
|
135
|
+
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
|
150
|
+
|
151
|
+
params = {}
|
152
|
+
params[:contract_type] = contract_type if contract_type
|
153
|
+
params[:strike_count] = strike_count if strike_count
|
154
|
+
params[:include_underlying_quote] = include_underlying_quote unless include_underlying_quote.nil?
|
155
|
+
params[:strategy] = strategy if strategy
|
156
|
+
params[:interval] = interval if interval
|
157
|
+
params[:strike] = strike if strike
|
158
|
+
params[:strike_range] = strike_range if strike_range
|
159
|
+
|
160
|
+
if expiration_date
|
161
|
+
exp_date = Date.parse(expiration_date)
|
162
|
+
params[:from_date] = exp_date
|
163
|
+
params[:to_date] = exp_date
|
164
|
+
else
|
165
|
+
params[:from_date] = Date.parse(from_date) if from_date
|
166
|
+
params[:to_date] = Date.parse(to_date) if to_date
|
167
|
+
end
|
168
|
+
|
169
|
+
params[:volatility] = volatility if volatility
|
170
|
+
params[:underlying_price] = underlying_price if underlying_price
|
171
|
+
params[:interest_rate] = interest_rate if interest_rate
|
172
|
+
params[:days_to_expiration] = days_to_expiration if days_to_expiration
|
173
|
+
params[:exp_month] = exp_month if exp_month
|
174
|
+
params[:option_type] = option_type if option_type
|
175
|
+
params[:entitlement] = entitlement if entitlement
|
176
|
+
|
177
|
+
log_debug("Making API request for option chain with params: #{params}")
|
178
|
+
response = client.get_option_chain(symbol.upcase, **params)
|
179
|
+
|
180
|
+
if response&.body
|
181
|
+
log_info("Successfully retrieved option chain for #{symbol}")
|
182
|
+
|
183
|
+
if max_delta || min_delta || max_strike || min_strike
|
184
|
+
begin
|
185
|
+
parsed_response = JSON.parse(response.body, symbolize_names: true)
|
186
|
+
|
187
|
+
log_debug("Applying option chain filtering")
|
188
|
+
|
189
|
+
filter = SchwabMCP::OptionChainFilter.new(
|
190
|
+
expiration_date: Date.parse(expiration_date),
|
191
|
+
max_delta: max_delta || 1.0,
|
192
|
+
min_delta: min_delta || 0.0,
|
193
|
+
max_strike: max_strike,
|
194
|
+
min_strike: min_strike
|
195
|
+
)
|
196
|
+
|
197
|
+
filtered_response = parsed_response.dup
|
198
|
+
|
199
|
+
if parsed_response[:callExpDateMap]
|
200
|
+
filtered_calls = filter.select(parsed_response[:callExpDateMap])
|
201
|
+
log_debug("Filtered #{filtered_calls.size} call options")
|
202
|
+
|
203
|
+
filtered_response[:callExpDateMap] = reconstruct_exp_date_map(
|
204
|
+
filtered_calls, expiration_date
|
205
|
+
)
|
206
|
+
end
|
207
|
+
|
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
|
227
|
+
log_error("Error applying option chain filter: #{e.message}")
|
228
|
+
end
|
229
|
+
else
|
230
|
+
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
|
+
}])
|
235
|
+
end
|
236
|
+
else
|
237
|
+
log_warn("Empty response from Schwab API for option chain: #{symbol}")
|
238
|
+
MCP::Tool::Response.new([{
|
239
|
+
type: "text",
|
240
|
+
text: "**No Data**: Empty response from Schwab API for option chain: #{symbol}"
|
241
|
+
}])
|
242
|
+
end
|
243
|
+
|
244
|
+
rescue => e
|
245
|
+
log_error("Error retrieving option chain for #{symbol}: #{e.message}")
|
246
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
247
|
+
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
|
+
}])
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
private
|
255
|
+
|
256
|
+
def self.reconstruct_exp_date_map(filtered_options, target_expiration_date)
|
257
|
+
return {} if filtered_options.empty?
|
258
|
+
|
259
|
+
grouped = {}
|
260
|
+
|
261
|
+
filtered_options.each do |option|
|
262
|
+
exp_date_key = "#{target_expiration_date}:#{option[:daysToExpiration] || 0}"
|
263
|
+
strike_key = "#{option[:strikePrice]}"
|
264
|
+
|
265
|
+
grouped[exp_date_key] ||= {}
|
266
|
+
grouped[exp_date_key][strike_key] ||= []
|
267
|
+
grouped[exp_date_key][strike_key] << option
|
268
|
+
end
|
269
|
+
|
270
|
+
grouped
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|