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,226 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require_relative "../loggable"
5
+
6
+ module SchwabMCP
7
+ module Tools
8
+ class CancelOrderTool < MCP::Tool
9
+ extend Loggable
10
+ description "Cancel a specific order by order ID using account name mapping"
11
+
12
+ input_schema(
13
+ properties: {
14
+ order_id: {
15
+ type: "string",
16
+ description: "The order ID to cancel",
17
+ pattern: "^\\d+$"
18
+ },
19
+ account_name: {
20
+ type: "string",
21
+ description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
22
+ pattern: "^[A-Z_]+_ACCOUNT$"
23
+ }
24
+ },
25
+ required: ["order_id", "account_name"]
26
+ )
27
+
28
+ annotations(
29
+ title: "Cancel Order",
30
+ read_only_hint: false,
31
+ destructive_hint: true,
32
+ idempotent_hint: false
33
+ )
34
+
35
+ def self.call(order_id:, account_name:, server_context:)
36
+ log_info("Attempting to cancel order ID: #{order_id} in account: #{account_name}")
37
+
38
+ unless account_name.end_with?('_ACCOUNT')
39
+ log_error("Invalid account name format: #{account_name}")
40
+ return MCP::Tool::Response.new([{
41
+ type: "text",
42
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
43
+ }])
44
+ end
45
+
46
+ unless order_id.match?(/^\d+$/)
47
+ log_error("Invalid order ID format: #{order_id}")
48
+ return MCP::Tool::Response.new([{
49
+ type: "text",
50
+ text: "**Error**: Order ID must be numeric. Example: '123456789'"
51
+ }])
52
+ end
53
+
54
+ begin
55
+ client = SchwabRb::Auth.init_client_easy(
56
+ ENV['SCHWAB_API_KEY'],
57
+ ENV['SCHWAB_APP_SECRET'],
58
+ ENV['SCHWAB_CALLBACK_URI'],
59
+ ENV['TOKEN_PATH']
60
+ )
61
+
62
+ unless client
63
+ log_error("Failed to initialize Schwab client")
64
+ return MCP::Tool::Response.new([{
65
+ type: "text",
66
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
67
+ }])
68
+ end
69
+
70
+ account_id = ENV[account_name]
71
+ unless account_id
72
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
73
+ log_error("Account name '#{account_name}' not found in environment variables")
74
+ return MCP::Tool::Response.new([{
75
+ type: "text",
76
+ 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."
77
+ }])
78
+ end
79
+
80
+ log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
81
+ log_debug("Fetching account numbers mapping")
82
+
83
+ account_numbers_response = client.get_account_numbers
84
+
85
+ unless account_numbers_response&.body
86
+ log_error("Failed to retrieve account numbers")
87
+ return MCP::Tool::Response.new([{
88
+ type: "text",
89
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
90
+ }])
91
+ end
92
+
93
+ account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
94
+ log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
95
+
96
+ account_hash = nil
97
+ account_mappings.each do |mapping|
98
+ if mapping[:accountNumber] == account_id
99
+ account_hash = mapping[:hashValue]
100
+ break
101
+ end
102
+ end
103
+
104
+ unless account_hash
105
+ log_error("Account ID not found in available accounts")
106
+ return MCP::Tool::Response.new([{
107
+ type: "text",
108
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
109
+ }])
110
+ end
111
+
112
+ log_debug("Found account hash for account ID: #{account_name}")
113
+ log_debug("Verifying order exists before attempting cancellation")
114
+
115
+ order_response = client.get_order(order_id, account_hash)
116
+
117
+ unless order_response&.body
118
+ log_warn("Order not found or empty response for order ID: #{order_id}")
119
+ return MCP::Tool::Response.new([{
120
+ type: "text",
121
+ text: "**Warning**: Order ID #{order_id} not found or empty response. Order may not exist in the specified account."
122
+ }])
123
+ end
124
+
125
+ order_data = JSON.parse(order_response.body)
126
+ order_status = order_data['status']
127
+ cancelable = order_data['cancelable']
128
+
129
+ log_debug("Order found - Status: #{order_status}, Cancelable: #{cancelable}")
130
+ if cancelable == false
131
+ log_warn("Order #{order_id} is not cancelable (Status: #{order_status})")
132
+ return MCP::Tool::Response.new([{
133
+ type: "text",
134
+ text: "**Warning**: Order ID #{order_id} cannot be cancelled.\n\n**Current Status**: #{order_status}\n**Cancelable**: #{cancelable}\n\nOrders that are already filled, cancelled, or expired cannot be cancelled."
135
+ }])
136
+ end
137
+
138
+ log_info("Attempting to cancel order ID: #{order_id} (Status: #{order_status})")
139
+ cancel_response = client.cancel_order(order_id, account_hash)
140
+
141
+ if cancel_response.respond_to?(:status) && cancel_response.status == 200
142
+ log_info("Successfully cancelled order ID: #{order_id}")
143
+ formatted_response = format_cancellation_success(order_id, account_name, order_data)
144
+ elsif cancel_response.respond_to?(:status) && cancel_response.status == 404
145
+ log_warn("Order not found during cancellation: #{order_id}")
146
+ return MCP::Tool::Response.new([{
147
+ type: "text",
148
+ text: "**Warning**: Order ID #{order_id} not found during cancellation. It may have already been cancelled or filled."
149
+ }])
150
+ else
151
+ log_info("Order cancellation request submitted for order ID: #{order_id}")
152
+ formatted_response = format_cancellation_success(order_id, account_name, order_data)
153
+ end
154
+
155
+ MCP::Tool::Response.new([{
156
+ type: "text",
157
+ text: formatted_response
158
+ }])
159
+
160
+ rescue JSON::ParserError => e
161
+ log_error("JSON parsing error: #{e.message}")
162
+ MCP::Tool::Response.new([{
163
+ type: "text",
164
+ text: "**Error**: Failed to parse API response: #{e.message}"
165
+ }])
166
+ rescue => e
167
+ log_error("Error cancelling order ID #{order_id}: #{e.message}")
168
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
169
+
170
+ error_message = if e.message.include?("401") || e.message.include?("403")
171
+ "**Error**: Authorization failed. Check your API credentials and permissions for order cancellation."
172
+ elsif e.message.include?("400")
173
+ "**Error**: Bad request. Order ID #{order_id} may be invalid or cannot be cancelled at this time."
174
+ elsif e.message.include?("404")
175
+ "**Error**: Order ID #{order_id} not found in the specified account."
176
+ else
177
+ "**Error** cancelling order ID #{order_id}: #{e.message}"
178
+ end
179
+
180
+ MCP::Tool::Response.new([{
181
+ type: "text",
182
+ text: "#{error_message}\n\n#{e.backtrace.first(3).join('\n')}"
183
+ }])
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ def self.format_cancellation_success(order_id, account_name, order_data)
190
+ friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
191
+
192
+ formatted = "**✅ Order Cancellation Successful**\n\n"
193
+ formatted += "**Order ID**: #{order_id}\n"
194
+ formatted += "**Account**: #{friendly_name} (#{account_name})\n\n"
195
+ formatted += "**Order Details:**\n"
196
+ formatted += "- Original Status: #{order_data['status']}\n" if order_data['status']
197
+ formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
198
+ formatted += "- Session: #{order_data['session']}\n" if order_data['session']
199
+ formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
200
+ formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
201
+ formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
202
+
203
+ if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
204
+ formatted += "\n**Instruments:**\n"
205
+ order_data['orderLegCollection'].each do |leg|
206
+ if leg['instrument']
207
+ symbol = leg['instrument']['symbol']
208
+ instruction = leg['instruction']
209
+ quantity = leg['quantity']
210
+ formatted += "- #{symbol}: #{instruction} #{quantity}\n"
211
+ end
212
+ end
213
+ end
214
+
215
+ formatted += "\n**Note**: The order cancellation has been submitted. Please verify the cancellation by checking your order status or using the `get_order_tool` or `list_account_orders_tool`."
216
+
217
+ formatted
218
+ end
219
+
220
+ def self.format_currency(amount)
221
+ return "0.00" if amount.nil?
222
+ "%.2f" % amount.to_f
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,104 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require "date"
5
+ require_relative "../loggable"
6
+
7
+ module SchwabMCP
8
+ module Tools
9
+ class GetMarketHoursTool < MCP::Tool
10
+ extend Loggable
11
+ description "Get market hours for specified markets using Schwab API"
12
+
13
+ input_schema(
14
+ properties: {
15
+ markets: {
16
+ type: "array",
17
+ description: "Markets for which to return trading hours",
18
+ items: {
19
+ type: "string",
20
+ enum: ["equity", "option", "bond", "future", "forex"]
21
+ },
22
+ minItems: 1
23
+ },
24
+ date: {
25
+ type: "string",
26
+ description: "Date for market hours in YYYY-MM-DD format (optional, defaults to today). Accepts values up to one year from today.",
27
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$"
28
+ }
29
+ },
30
+ required: ["markets"]
31
+ )
32
+
33
+ annotations(
34
+ title: "Get Market Hours",
35
+ read_only_hint: true,
36
+ destructive_hint: false,
37
+ idempotent_hint: true
38
+ )
39
+
40
+ def self.call(markets:, date: nil, server_context:)
41
+ log_info("Getting market hours for markets: #{markets.join(', ')}")
42
+ log_debug("Date parameter: #{date || 'today'}")
43
+
44
+ begin
45
+ client = SchwabRb::Auth.init_client_easy(
46
+ ENV['SCHWAB_API_KEY'],
47
+ ENV['SCHWAB_APP_SECRET'],
48
+ ENV['SCHWAB_CALLBACK_URI'],
49
+ ENV['TOKEN_PATH']
50
+ )
51
+
52
+ unless client
53
+ log_error("Failed to initialize Schwab client")
54
+ return MCP::Tool::Response.new([{
55
+ type: "text",
56
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
57
+ }])
58
+ end
59
+
60
+ # Parse date if provided
61
+ parsed_date = nil
62
+ if date
63
+ begin
64
+ parsed_date = Date.parse(date)
65
+ log_debug("Parsed date: #{parsed_date}")
66
+ rescue ArgumentError => e
67
+ log_error("Invalid date format: #{date}")
68
+ return MCP::Tool::Response.new([{
69
+ type: "text",
70
+ text: "**Error**: Invalid date format '#{date}'. Please use YYYY-MM-DD format."
71
+ }])
72
+ end
73
+ end
74
+
75
+ log_debug("Making API request for markets: #{markets.join(', ')}")
76
+ response = client.get_market_hours(markets, date: parsed_date)
77
+
78
+ if response&.body
79
+ log_info("Successfully retrieved market hours for #{markets.join(', ')}")
80
+ date_info = date ? " for #{date}" : " for today"
81
+ MCP::Tool::Response.new([{
82
+ type: "text",
83
+ text: "**Market Hours#{date_info}:**\n\n```json\n#{response.body}\n```"
84
+ }])
85
+ else
86
+ log_warn("Empty response from Schwab API for markets: #{markets.join(', ')}")
87
+ MCP::Tool::Response.new([{
88
+ type: "text",
89
+ text: "**No Data**: Empty response from Schwab API for markets: #{markets.join(', ')}"
90
+ }])
91
+ end
92
+
93
+ rescue => e
94
+ log_error("Error retrieving market hours for #{markets.join(', ')}: #{e.message}")
95
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
96
+ MCP::Tool::Response.new([{
97
+ type: "text",
98
+ text: "**Error** retrieving market hours for #{markets.join(', ')}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
99
+ }])
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,263 @@
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 GetOrderTool < MCP::Tool
10
+ extend Loggable
11
+ description "Get details for a specific order by order ID using account name mapping"
12
+
13
+ input_schema(
14
+ properties: {
15
+ order_id: {
16
+ type: "string",
17
+ description: "The order ID to retrieve details for",
18
+ pattern: "^\\d+$"
19
+ },
20
+ account_name: {
21
+ type: "string",
22
+ description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
23
+ pattern: "^[A-Z_]+_ACCOUNT$"
24
+ }
25
+ },
26
+ required: ["order_id", "account_name"]
27
+ )
28
+
29
+ annotations(
30
+ title: "Get Order Details",
31
+ read_only_hint: true,
32
+ destructive_hint: false,
33
+ idempotent_hint: true
34
+ )
35
+
36
+ def self.call(order_id:, account_name:, server_context:)
37
+ log_info("Getting order details for order ID: #{order_id} in account: #{account_name}")
38
+
39
+ unless account_name.end_with?('_ACCOUNT')
40
+ log_error("Invalid account name format: #{account_name}")
41
+ return MCP::Tool::Response.new([{
42
+ type: "text",
43
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
44
+ }])
45
+ end
46
+
47
+ unless order_id.match?(/^\d+$/)
48
+ log_error("Invalid order ID format: #{order_id}")
49
+ return MCP::Tool::Response.new([{
50
+ type: "text",
51
+ text: "**Error**: Order ID must be numeric. Example: '123456789'"
52
+ }])
53
+ end
54
+
55
+ begin
56
+ client = SchwabRb::Auth.init_client_easy(
57
+ ENV['SCHWAB_API_KEY'],
58
+ ENV['SCHWAB_APP_SECRET'],
59
+ ENV['SCHWAB_CALLBACK_URI'],
60
+ ENV['TOKEN_PATH']
61
+ )
62
+
63
+ unless client
64
+ log_error("Failed to initialize Schwab client")
65
+ return MCP::Tool::Response.new([{
66
+ type: "text",
67
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
68
+ }])
69
+ end
70
+
71
+ account_id = ENV[account_name]
72
+ unless account_id
73
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
74
+ log_error("Account name '#{account_name}' not found in environment variables")
75
+ return MCP::Tool::Response.new([{
76
+ type: "text",
77
+ 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."
78
+ }])
79
+ end
80
+
81
+ log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
82
+ log_debug("Fetching account numbers mapping")
83
+
84
+ account_numbers_response = client.get_account_numbers
85
+
86
+ unless account_numbers_response&.body
87
+ log_error("Failed to retrieve account numbers")
88
+ return MCP::Tool::Response.new([{
89
+ type: "text",
90
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
91
+ }])
92
+ end
93
+
94
+ account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
95
+ log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
96
+
97
+ account_hash = nil
98
+ account_mappings.each do |mapping|
99
+ if mapping[:accountNumber] == account_id
100
+ account_hash = mapping[:hashValue]
101
+ break
102
+ end
103
+ end
104
+
105
+ unless account_hash
106
+ log_error("Account ID not found in available accounts")
107
+ return MCP::Tool::Response.new([{
108
+ type: "text",
109
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
110
+ }])
111
+ end
112
+
113
+ log_debug("Found account hash for account ID: #{account_name}")
114
+ log_debug("Fetching order details for order ID: #{order_id}")
115
+
116
+ order_response = client.get_order(order_id, account_hash)
117
+
118
+ if order_response&.body
119
+ log_info("Successfully retrieved order details for order ID: #{order_id}")
120
+ order_data = JSON.parse(order_response.body)
121
+
122
+ formatted_response = format_order_data(order_data, order_id, account_name)
123
+
124
+ MCP::Tool::Response.new([{
125
+ type: "text",
126
+ text: formatted_response
127
+ }])
128
+ else
129
+ log_warn("Empty response from Schwab API for order ID: #{order_id}")
130
+ MCP::Tool::Response.new([{
131
+ type: "text",
132
+ text: "**No Data**: Empty response from Schwab API for order ID: #{order_id}. Order may not exist or may be in a different account."
133
+ }])
134
+ end
135
+
136
+ rescue JSON::ParserError => e
137
+ log_error("JSON parsing error: #{e.message}")
138
+ MCP::Tool::Response.new([{
139
+ type: "text",
140
+ text: "**Error**: Failed to parse API response: #{e.message}"
141
+ }])
142
+ rescue => e
143
+ log_error("Error retrieving order details for order ID #{order_id}: #{e.message}")
144
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
145
+ MCP::Tool::Response.new([{
146
+ type: "text",
147
+ text: "**Error** retrieving order details for order ID #{order_id}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
148
+ }])
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def self.format_order_data(order_data, order_id, account_name)
155
+ friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
156
+
157
+ formatted = "**Order Details for Order ID #{order_id}:**\n\n"
158
+ formatted += "**Account:** #{friendly_name} (#{account_name})\n\n"
159
+
160
+ formatted += "**Order Information:**\n"
161
+ formatted += "- Order ID: #{order_data['orderId']}\n" if order_data['orderId']
162
+ formatted += "- Status: #{order_data['status']}\n" if order_data['status']
163
+ formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
164
+ formatted += "- Session: #{order_data['session']}\n" if order_data['session']
165
+ formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
166
+ formatted += "- Complex Order Strategy Type: #{order_data['complexOrderStrategyType']}\n" if order_data['complexOrderStrategyType']
167
+ formatted += "- Cancelable: #{order_data['cancelable']}\n" if order_data.key?('cancelable')
168
+ formatted += "- Editable: #{order_data['editable']}\n" if order_data.key?('editable')
169
+
170
+ formatted += "\n**Timing:**\n"
171
+ formatted += "- Entered Time: #{order_data['enteredTime']}\n" if order_data['enteredTime']
172
+ formatted += "- Close Time: #{order_data['closeTime']}\n" if order_data['closeTime']
173
+
174
+ formatted += "\n**Quantity & Pricing:**\n"
175
+ formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
176
+ formatted += "- Filled Quantity: #{order_data['filledQuantity']}\n" if order_data['filledQuantity']
177
+ formatted += "- Remaining Quantity: #{order_data['remainingQuantity']}\n" if order_data['remainingQuantity']
178
+ formatted += "- Requested Destination: #{order_data['requestedDestination']}\n" if order_data['requestedDestination']
179
+ formatted += "- Destination Link Name: #{order_data['destinationLinkName']}\n" if order_data['destinationLinkName']
180
+ formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
181
+ formatted += "- Stop Price: $#{format_currency(order_data['stopPrice'])}\n" if order_data['stopPrice']
182
+ formatted += "- Stop Price Link Basis: #{order_data['stopPriceLinkBasis']}\n" if order_data['stopPriceLinkBasis']
183
+ formatted += "- Stop Price Link Type: #{order_data['stopPriceLinkType']}\n" if order_data['stopPriceLinkType']
184
+ formatted += "- Stop Price Offset: $#{format_currency(order_data['stopPriceOffset'])}\n" if order_data['stopPriceOffset']
185
+ formatted += "- Stop Type: #{order_data['stopType']}\n" if order_data['stopType']
186
+
187
+ if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
188
+ formatted += "\n**Order Legs:**\n"
189
+ order_data['orderLegCollection'].each_with_index do |leg, index|
190
+ formatted += "**Leg #{index + 1}:**\n"
191
+ formatted += "- Instruction: #{leg['instruction']}\n" if leg['instruction']
192
+ formatted += "- Quantity: #{leg['quantity']}\n" if leg['quantity']
193
+ formatted += "- Position Effect: #{leg['positionEffect']}\n" if leg['positionEffect']
194
+ formatted += "- Quantity Type: #{leg['quantityType']}\n" if leg['quantityType']
195
+
196
+ if leg['instrument']
197
+ instrument = leg['instrument']
198
+ formatted += "- **Instrument:**\n"
199
+ formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
200
+ formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
201
+ formatted += " * Description: #{instrument['description']}\n" if instrument['description']
202
+ formatted += " * CUSIP: #{instrument['cusip']}\n" if instrument['cusip']
203
+ formatted += " * Net Change: #{instrument['netChange']}\n" if instrument['netChange']
204
+
205
+ if instrument['putCall']
206
+ formatted += " * Option Type: #{instrument['putCall']}\n"
207
+ formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
208
+ formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
209
+ formatted += " * Days to Expiration: #{instrument['daysToExpiration']}\n" if instrument['daysToExpiration']
210
+ formatted += " * Expiration Type: #{instrument['expirationType']}\n" if instrument['expirationType']
211
+ formatted += " * Exercise Type: #{instrument['exerciseType']}\n" if instrument['exerciseType']
212
+ formatted += " * Settlement Type: #{instrument['settlementType']}\n" if instrument['settlementType']
213
+ formatted += " * Deliverables: #{instrument['deliverables']}\n" if instrument['deliverables']
214
+ end
215
+ end
216
+ formatted += "\n" unless index == order_data['orderLegCollection'].length - 1
217
+ end
218
+ end
219
+
220
+ if order_data['childOrderStrategies'] && order_data['childOrderStrategies'].any?
221
+ formatted += "\n**Child Order Strategies:**\n"
222
+ formatted += "- Number of Child Orders: #{order_data['childOrderStrategies'].length}\n"
223
+ order_data['childOrderStrategies'].each_with_index do |child, index|
224
+ formatted += "- Child Order #{index + 1}: #{child['orderId']} (Status: #{child['status']})\n" if child['orderId'] && child['status']
225
+ end
226
+ end
227
+
228
+ if order_data['orderActivityCollection'] && order_data['orderActivityCollection'].any?
229
+ formatted += "\n**Order Activities:**\n"
230
+ order_data['orderActivityCollection'].each_with_index do |activity, index|
231
+ formatted += "**Activity #{index + 1}:**\n"
232
+ formatted += "- Activity Type: #{activity['activityType']}\n" if activity['activityType']
233
+ formatted += "- Execution Type: #{activity['executionType']}\n" if activity['executionType']
234
+ formatted += "- Quantity: #{activity['quantity']}\n" if activity['quantity']
235
+ formatted += "- Order Remaining Quantity: #{activity['orderRemainingQuantity']}\n" if activity['orderRemainingQuantity']
236
+
237
+ if activity['executionLegs'] && activity['executionLegs'].any?
238
+ activity['executionLegs'].each_with_index do |exec_leg, leg_index|
239
+ formatted += "- **Execution Leg #{leg_index + 1}:**\n"
240
+ formatted += " * Leg ID: #{exec_leg['legId']}\n" if exec_leg['legId']
241
+ formatted += " * Price: $#{format_currency(exec_leg['price'])}\n" if exec_leg['price']
242
+ formatted += " * Quantity: #{exec_leg['quantity']}\n" if exec_leg['quantity']
243
+ formatted += " * Mismarked Quantity: #{exec_leg['mismarkedQuantity']}\n" if exec_leg['mismarkedQuantity']
244
+ formatted += " * Time: #{exec_leg['time']}\n" if exec_leg['time']
245
+ end
246
+ end
247
+ formatted += "\n" unless index == order_data['orderActivityCollection'].length - 1
248
+ end
249
+ end
250
+
251
+ redacted_data = Redactor.redact(order_data)
252
+ formatted += "\n**Full Response (Redacted):**\n"
253
+ formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
254
+ formatted
255
+ end
256
+
257
+ def self.format_currency(amount)
258
+ return "0.00" if amount.nil?
259
+ "%.2f" % amount.to_f
260
+ end
261
+ end
262
+ end
263
+ end