schwab_mcp 0.1.0 → 0.3.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/CHANGELOG.md +12 -0
- data/CLAUDE.md +124 -0
- data/README.md +1 -7
- 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 +15 -4
- data/exe/schwab_token_refresh +12 -11
- data/exe/schwab_token_reset +11 -10
- 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 -81
- data/lib/schwab_mcp/tools/get_account_names_tool.rb +58 -0
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
- data/lib/schwab_mcp/tools/get_order_tool.rb +50 -137
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
- data/lib/schwab_mcp/tools/help_tool.rb +12 -33
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +36 -90
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -98
- data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
- data/lib/schwab_mcp/tools/option_chain_tool.rb +132 -84
- data/lib/schwab_mcp/tools/place_order_tool.rb +111 -141
- data/lib/schwab_mcp/tools/preview_order_tool.rb +71 -81
- data/lib/schwab_mcp/tools/quote_tool.rb +33 -28
- data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
- data/lib/schwab_mcp/tools/replace_order_tool.rb +110 -140
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -98
- data/lib/schwab_mcp/version.rb +1 -1
- data/lib/schwab_mcp.rb +11 -10
- metadata +12 -9
- 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/list_schwab_accounts_tool.rb +0 -162
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
- data/start_mcp_server.sh +0 -4
@@ -1,40 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'schwab_rb'
|
4
|
-
require_relative 'iron_condor_order'
|
5
|
-
require_relative 'vertical_order'
|
6
|
-
|
7
|
-
module SchwabMCP
|
8
|
-
module Orders
|
9
|
-
class OrderFactory
|
10
|
-
class << self
|
11
|
-
def build(**options)
|
12
|
-
case options[:strategy_type] || 'none'
|
13
|
-
when 'ironcondor'
|
14
|
-
IronCondorOrder.build(
|
15
|
-
put_short_symbol: options[:put_short_symbol],
|
16
|
-
put_long_symbol: options[:put_long_symbol],
|
17
|
-
call_short_symbol: options[:call_short_symbol],
|
18
|
-
call_long_symbol: options[:call_long_symbol],
|
19
|
-
price: options[:price],
|
20
|
-
account_number: options[:account_number],
|
21
|
-
order_instruction: options[:order_instruction] || :open,
|
22
|
-
quantity: options[:quantity] || 1
|
23
|
-
)
|
24
|
-
when 'callspread', 'putspread'
|
25
|
-
VerticalOrder.build(
|
26
|
-
short_leg_symbol: options[:short_leg_symbol],
|
27
|
-
long_leg_symbol: options[:long_leg_symbol],
|
28
|
-
price: options[:price],
|
29
|
-
account_number: options[:account_number],
|
30
|
-
order_instruction: options[:order_instruction] || :open,
|
31
|
-
quantity: options[:quantity] || 1
|
32
|
-
)
|
33
|
-
else
|
34
|
-
raise "Unsupported trade strategy: #{options[:strategy_type] || 'none'}"
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
@@ -1,62 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'schwab_rb'
|
4
|
-
|
5
|
-
module SchwabMCP
|
6
|
-
module Orders
|
7
|
-
class VerticalOrder
|
8
|
-
class << self
|
9
|
-
def build(short_leg_symbol:, long_leg_symbol:, price:, account_number:, order_instruction: :open, quantity: 1)
|
10
|
-
schwab_order_builder.new.tap do |builder|
|
11
|
-
builder.set_account_number(account_number)
|
12
|
-
builder.set_order_strategy_type('SINGLE')
|
13
|
-
builder.set_session(SchwabRb::Orders::Session::NORMAL)
|
14
|
-
builder.set_duration(SchwabRb::Orders::Duration::DAY)
|
15
|
-
builder.set_order_type(order_type(order_instruction))
|
16
|
-
builder.set_complex_order_strategy_type(SchwabRb::Order::ComplexOrderStrategyTypes::VERTICAL)
|
17
|
-
builder.set_quantity(quantity)
|
18
|
-
builder.set_price(price)
|
19
|
-
builder.add_option_leg(
|
20
|
-
short_leg_instruction(order_instruction),
|
21
|
-
short_leg_symbol,
|
22
|
-
quantity
|
23
|
-
)
|
24
|
-
builder.add_option_leg(
|
25
|
-
long_leg_instruction(order_instruction),
|
26
|
-
long_leg_symbol,
|
27
|
-
quantity
|
28
|
-
)
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def order_type(order_instruction)
|
33
|
-
if order_instruction == :open
|
34
|
-
SchwabRb::Order::Types::NET_CREDIT
|
35
|
-
else
|
36
|
-
SchwabRb::Order::Types::NET_DEBIT
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
def short_leg_instruction(order_instruction)
|
41
|
-
if order_instruction == :open
|
42
|
-
SchwabRb::Orders::OptionInstructions::SELL_TO_OPEN
|
43
|
-
else
|
44
|
-
SchwabRb::Orders::OptionInstructions::BUY_TO_CLOSE
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
def long_leg_instruction(order_instruction)
|
49
|
-
if order_instruction == :open
|
50
|
-
SchwabRb::Orders::OptionInstructions::BUY_TO_OPEN
|
51
|
-
else
|
52
|
-
SchwabRb::Orders::OptionInstructions::SELL_TO_CLOSE
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
def schwab_order_builder
|
57
|
-
SchwabRb::Orders::Builder
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
|
-
end
|
62
|
-
end
|
@@ -1,162 +0,0 @@
|
|
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
|
@@ -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
|
data/start_mcp_server.sh
DELETED