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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.copilotignore +3 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +157 -0
  9. data/Rakefile +12 -0
  10. data/exe/schwab_mcp +19 -0
  11. data/exe/schwab_token_refresh +38 -0
  12. data/exe/schwab_token_reset +49 -0
  13. data/lib/schwab_mcp/loggable.rb +31 -0
  14. data/lib/schwab_mcp/logger.rb +62 -0
  15. data/lib/schwab_mcp/option_chain_filter.rb +213 -0
  16. data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
  17. data/lib/schwab_mcp/orders/order_factory.rb +40 -0
  18. data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
  19. data/lib/schwab_mcp/redactor.rb +210 -0
  20. data/lib/schwab_mcp/resources/.keep +0 -0
  21. data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
  22. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
  23. data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
  24. data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
  25. data/lib/schwab_mcp/tools/help_tool.rb +406 -0
  26. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
  27. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
  28. data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
  29. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
  30. data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
  31. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
  32. data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
  33. data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
  34. data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
  35. data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
  36. data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
  37. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
  38. data/lib/schwab_mcp/version.rb +5 -0
  39. data/lib/schwab_mcp.rb +107 -0
  40. data/sig/schwab_mcp.rbs +4 -0
  41. data/start_mcp_server.sh +4 -0
  42. 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