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,295 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require "date"
5
+ require_relative "../loggable"
6
+ require_relative "../redactor"
7
+
8
+ module SchwabMCP
9
+ module Tools
10
+ class ListAccountOrdersTool < MCP::Tool
11
+ extend Loggable
12
+ description "List orders for a specific account using account name mapping"
13
+
14
+ input_schema(
15
+ properties: {
16
+ account_name: {
17
+ type: "string",
18
+ description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
19
+ pattern: "^[A-Z_]+_ACCOUNT$"
20
+ },
21
+ max_results: {
22
+ type: "integer",
23
+ description: "Maximum number of orders to retrieve (optional)",
24
+ minimum: 1
25
+ },
26
+ from_date: {
27
+ type: "string",
28
+ description: "Start date for orders in YYYY-MM-DD format",
29
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$"
30
+ },
31
+ to_date: {
32
+ type: "string",
33
+ description: "End date for orders in YYYY-MM-DD format",
34
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$"
35
+ },
36
+ status: {
37
+ type: "string",
38
+ description: "Filter orders by status (AWAITING_PARENT_ORDER, AWAITING_CONDITION, AWAITING_STOP_CONDITION, AWAITING_MANUAL_REVIEW, ACCEPTED, AWAITING_UR_OUT, PENDING_ACTIVATION, QUEUED, WORKING, REJECTED, PENDING_CANCEL, CANCELED, PENDING_REPLACE, REPLACED, FILLED, EXPIRED, NEW, AWAITING_RELEASE_TIME, PENDING_ACKNOWLEDGEMENT, PENDING_RECALL, UNKNOWN)",
39
+ enum: [
40
+ "AWAITING_PARENT_ORDER",
41
+ "AWAITING_CONDITION",
42
+ "AWAITING_STOP_CONDITION",
43
+ "AWAITING_MANUAL_REVIEW",
44
+ "ACCEPTED",
45
+ "AWAITING_UR_OUT",
46
+ "PENDING_ACTIVATION",
47
+ "QUEUED",
48
+ "WORKING",
49
+ "REJECTED",
50
+ "PENDING_CANCEL",
51
+ "CANCELED",
52
+ "PENDING_REPLACE",
53
+ "REPLACED",
54
+ "FILLED",
55
+ "EXPIRED",
56
+ "NEW",
57
+ "AWAITING_RELEASE_TIME",
58
+ "PENDING_ACKNOWLEDGEMENT",
59
+ "PENDING_RECALL",
60
+ "UNKNOWN"
61
+ ]
62
+ }
63
+ },
64
+ required: ["account_name", "from_date", "to_date"]
65
+ )
66
+
67
+ annotations(
68
+ title: "List Account Orders",
69
+ read_only_hint: true,
70
+ destructive_hint: false,
71
+ idempotent_hint: true
72
+ )
73
+
74
+ def self.call(account_name:, max_results: nil, from_date: nil, to_date: nil, status: nil, server_context:)
75
+ log_info("Listing orders for account name: #{account_name}")
76
+
77
+ unless account_name.end_with?('_ACCOUNT')
78
+ log_error("Invalid account name format: #{account_name}")
79
+ return MCP::Tool::Response.new([{
80
+ type: "text",
81
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
82
+ }])
83
+ end
84
+
85
+ begin
86
+ client = SchwabRb::Auth.init_client_easy(
87
+ ENV['SCHWAB_API_KEY'],
88
+ ENV['SCHWAB_APP_SECRET'],
89
+ ENV['SCHWAB_CALLBACK_URI'],
90
+ ENV['TOKEN_PATH']
91
+ )
92
+
93
+ unless client
94
+ log_error("Failed to initialize Schwab client")
95
+ return MCP::Tool::Response.new([{
96
+ type: "text",
97
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
98
+ }])
99
+ end
100
+
101
+ account_id = ENV[account_name]
102
+ unless account_id
103
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
104
+ log_error("Account name '#{account_name}' not found in environment variables")
105
+ return MCP::Tool::Response.new([{
106
+ type: "text",
107
+ 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."
108
+ }])
109
+ end
110
+
111
+ log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
112
+ log_debug("Fetching account numbers mapping")
113
+
114
+ account_numbers_response = client.get_account_numbers
115
+
116
+ unless account_numbers_response&.body
117
+ log_error("Failed to retrieve account numbers")
118
+ return MCP::Tool::Response.new([{
119
+ type: "text",
120
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
121
+ }])
122
+ end
123
+
124
+ account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
125
+ log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
126
+
127
+ account_hash = nil
128
+ account_mappings.each do |mapping|
129
+ if mapping[:accountNumber] == account_id
130
+ account_hash = mapping[:hashValue]
131
+ break
132
+ end
133
+ end
134
+
135
+ unless account_hash
136
+ log_error("Account ID not found in available accounts")
137
+ return MCP::Tool::Response.new([{
138
+ type: "text",
139
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
140
+ }])
141
+ end
142
+
143
+ log_debug("Found account hash for account ID: #{account_name}")
144
+
145
+ from_datetime = nil
146
+ to_datetime = nil
147
+
148
+ if from_date
149
+ begin
150
+ from_datetime = DateTime.parse("#{from_date}T00:00:00Z")
151
+ rescue Date::Error => e
152
+ log_error("Invalid from_date format: #{from_date}")
153
+ return MCP::Tool::Response.new([{
154
+ type: "text",
155
+ text: "**Error**: Invalid from_date format. Use YYYY-MM-DD format."
156
+ }])
157
+ end
158
+ end
159
+
160
+ if to_date
161
+ begin
162
+ to_datetime = DateTime.parse("#{to_date}T23:59:59Z")
163
+ rescue Date::Error => e
164
+ log_error("Invalid to_date format: #{to_date}")
165
+ return MCP::Tool::Response.new([{
166
+ type: "text",
167
+ text: "**Error**: Invalid to_date format. Use YYYY-MM-DD format."
168
+ }])
169
+ end
170
+ end
171
+
172
+ log_debug("Fetching orders with params - max_results: #{max_results}, from_datetime: #{from_datetime}, to_datetime: #{to_datetime}, status: #{status}")
173
+
174
+ orders_response = client.get_account_orders(
175
+ account_hash,
176
+ max_results: max_results,
177
+ from_entered_datetime: from_datetime,
178
+ to_entered_datetime: to_datetime,
179
+ status: status
180
+ )
181
+
182
+ if orders_response&.body
183
+ log_info("Successfully retrieved orders for #{account_name}")
184
+ orders_data = JSON.parse(orders_response.body)
185
+
186
+ formatted_response = format_orders_data(orders_data, account_name, {
187
+ max_results: max_results,
188
+ from_date: from_date,
189
+ to_date: to_date,
190
+ status: status
191
+ })
192
+
193
+ MCP::Tool::Response.new([{
194
+ type: "text",
195
+ text: formatted_response
196
+ }])
197
+ else
198
+ log_warn("Empty response from Schwab API for account: #{account_name}")
199
+ MCP::Tool::Response.new([{
200
+ type: "text",
201
+ text: "**No Data**: Empty response from Schwab API for account: #{account_name}"
202
+ }])
203
+ end
204
+
205
+ rescue JSON::ParserError => e
206
+ log_error("JSON parsing error: #{e.message}")
207
+ MCP::Tool::Response.new([{
208
+ type: "text",
209
+ text: "**Error**: Failed to parse API response: #{e.message}"
210
+ }])
211
+ rescue => e
212
+ log_error("Error retrieving orders for #{account_name}: #{e.message}")
213
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
214
+ MCP::Tool::Response.new([{
215
+ type: "text",
216
+ text: "**Error** retrieving orders for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
217
+ }])
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ def self.format_orders_data(orders_data, account_name, filters)
224
+ friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
225
+
226
+ formatted = "**Orders for #{friendly_name} (#{account_name}):**\n\n"
227
+
228
+ if filters.any? { |k, v| v }
229
+ formatted += "**Filters Applied:**\n"
230
+ formatted += "- Max Results: #{filters[:max_results]}\n" if filters[:max_results]
231
+ formatted += "- From Date: #{filters[:from_date]}\n" if filters[:from_date]
232
+ formatted += "- To Date: #{filters[:to_date]}\n" if filters[:to_date]
233
+ formatted += "- Status: #{filters[:status]}\n" if filters[:status]
234
+ formatted += "\n"
235
+ end
236
+
237
+ if orders_data.is_a?(Array)
238
+ orders = orders_data
239
+ else
240
+ orders = [orders_data]
241
+ end
242
+
243
+ formatted += "**Orders Summary:**\n"
244
+ formatted += "- Total Orders: #{orders.length}\n\n"
245
+
246
+ if orders.length > 0
247
+ formatted += "**Order Details:**\n"
248
+ orders.each_with_index do |order, index|
249
+ formatted += format_single_order(order, index + 1)
250
+ formatted += "\n" unless index == orders.length - 1
251
+ end
252
+ else
253
+ formatted += "No orders found matching the specified criteria.\n"
254
+ end
255
+
256
+ redacted_data = Redactor.redact(orders_data)
257
+ formatted += "\n**Full Response (Redacted):**\n"
258
+ formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
259
+ formatted
260
+ end
261
+
262
+ def self.format_single_order(order, order_num)
263
+ formatted = "**Order #{order_num}:**\n"
264
+ formatted += "- Order ID: #{order['orderId']}\n" if order['orderId']
265
+ formatted += "- Status: #{order['status']}\n" if order['status']
266
+ formatted += "- Order Type: #{order['orderType']}\n" if order['orderType']
267
+ formatted += "- Session: #{order['session']}\n" if order['session']
268
+ formatted += "- Duration: #{order['duration']}\n" if order['duration']
269
+ formatted += "- Entered Time: #{order['enteredTime']}\n" if order['enteredTime']
270
+ formatted += "- Close Time: #{order['closeTime']}\n" if order['closeTime']
271
+ formatted += "- Quantity: #{order['quantity']}\n" if order['quantity']
272
+ formatted += "- Filled Quantity: #{order['filledQuantity']}\n" if order['filledQuantity']
273
+ formatted += "- Price: $#{format_currency(order['price'])}\n" if order['price']
274
+
275
+ if order['orderLegCollection'] && order['orderLegCollection'].any?
276
+ formatted += "- Instruments:\n"
277
+ order['orderLegCollection'].each do |leg|
278
+ if leg['instrument']
279
+ symbol = leg['instrument']['symbol']
280
+ instruction = leg['instruction']
281
+ formatted += " * #{symbol} - #{instruction}\n"
282
+ end
283
+ end
284
+ end
285
+
286
+ formatted
287
+ end
288
+
289
+ def self.format_currency(amount)
290
+ return "0.00" if amount.nil?
291
+ "%.2f" % amount.to_f
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,311 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require "date"
5
+ require_relative "../loggable"
6
+ require_relative "../redactor"
7
+
8
+ module SchwabMCP
9
+ module Tools
10
+ class ListAccountTransactionsTool < MCP::Tool
11
+ extend Loggable
12
+ description "List transactions for a specific account using account name mapping"
13
+
14
+ input_schema(
15
+ properties: {
16
+ account_name: {
17
+ type: "string",
18
+ description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
19
+ pattern: "^[A-Z_]+_ACCOUNT$"
20
+ },
21
+ start_date: {
22
+ type: "string",
23
+ description: "Start date for transactions in YYYY-MM-DD format (default: 60 days ago)",
24
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$"
25
+ },
26
+ end_date: {
27
+ type: "string",
28
+ description: "End date for transactions in YYYY-MM-DD format (default: today)",
29
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$"
30
+ },
31
+ transaction_types: {
32
+ type: "array",
33
+ description: "Array of transaction types to filter by (optional, if not provided all types will be included)",
34
+ items: {
35
+ type: "string",
36
+ enum: [
37
+ "TRADE",
38
+ "RECEIVE_AND_DELIVER",
39
+ "DIVIDEND_OR_INTEREST",
40
+ "ACH_RECEIPT",
41
+ "ACH_DISBURSEMENT",
42
+ "CASH_RECEIPT",
43
+ "CASH_DISBURSEMENT",
44
+ "ELECTRONIC_FUND",
45
+ "WIRE_OUT",
46
+ "WIRE_IN",
47
+ "JOURNAL",
48
+ "MEMORANDUM",
49
+ "MARGIN_CALL",
50
+ "MONEY_MARKET",
51
+ "SMA_ADJUSTMENT"
52
+ ]
53
+ }
54
+ },
55
+ symbol: {
56
+ type: "string",
57
+ description: "Filter transactions by the specified symbol (optional)"
58
+ }
59
+ },
60
+ required: ["account_name"]
61
+ )
62
+
63
+ annotations(
64
+ title: "List Account Transactions",
65
+ read_only_hint: true,
66
+ destructive_hint: false,
67
+ idempotent_hint: true
68
+ )
69
+
70
+ def self.call(account_name:, start_date: nil, end_date: nil, transaction_types: nil, symbol: nil, server_context:)
71
+ log_info("Listing transactions for account name: #{account_name}")
72
+
73
+ unless account_name.end_with?('_ACCOUNT')
74
+ log_error("Invalid account name format: #{account_name}")
75
+ return MCP::Tool::Response.new([{
76
+ type: "text",
77
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
78
+ }])
79
+ end
80
+
81
+ begin
82
+ client = SchwabRb::Auth.init_client_easy(
83
+ ENV['SCHWAB_API_KEY'],
84
+ ENV['SCHWAB_APP_SECRET'],
85
+ ENV['SCHWAB_CALLBACK_URI'],
86
+ ENV['TOKEN_PATH']
87
+ )
88
+
89
+ unless client
90
+ log_error("Failed to initialize Schwab client")
91
+ return MCP::Tool::Response.new([{
92
+ type: "text",
93
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
94
+ }])
95
+ end
96
+
97
+ account_id = ENV[account_name]
98
+ unless account_id
99
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
100
+ log_error("Account name '#{account_name}' not found in environment variables")
101
+ return MCP::Tool::Response.new([{
102
+ type: "text",
103
+ 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."
104
+ }])
105
+ end
106
+
107
+ log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
108
+ log_debug("Fetching account numbers mapping")
109
+
110
+ account_numbers_response = client.get_account_numbers
111
+
112
+ unless account_numbers_response&.body
113
+ log_error("Failed to retrieve account numbers")
114
+ return MCP::Tool::Response.new([{
115
+ type: "text",
116
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
117
+ }])
118
+ end
119
+
120
+ account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
121
+ log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
122
+
123
+ account_hash = nil
124
+ account_mappings.each do |mapping|
125
+ if mapping[:accountNumber] == account_id
126
+ account_hash = mapping[:hashValue]
127
+ break
128
+ end
129
+ end
130
+
131
+ unless account_hash
132
+ log_error("Account ID not found in available accounts")
133
+ return MCP::Tool::Response.new([{
134
+ type: "text",
135
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
136
+ }])
137
+ end
138
+
139
+ log_debug("Found account hash for account ID: #{account_name}")
140
+
141
+ start_date_obj = nil
142
+ end_date_obj = nil
143
+
144
+ if start_date
145
+ begin
146
+ start_date_obj = DateTime.parse("#{start_date}T00:00:00Z")
147
+ rescue Date::Error => e
148
+ log_error("Invalid start_date format: #{start_date}")
149
+ return MCP::Tool::Response.new([{
150
+ type: "text",
151
+ text: "**Error**: Invalid start_date format. Use YYYY-MM-DD format."
152
+ }])
153
+ end
154
+ end
155
+
156
+ if end_date
157
+ begin
158
+ end_date_obj = DateTime.parse("#{end_date}T23:59:59Z")
159
+ rescue Date::Error => e
160
+ log_error("Invalid end_date format: #{end_date}")
161
+ return MCP::Tool::Response.new([{
162
+ type: "text",
163
+ text: "**Error**: Invalid end_date format. Use YYYY-MM-DD format."
164
+ }])
165
+ end
166
+ end
167
+
168
+ log_debug("Fetching transactions with params - start_date: #{start_date_obj}, end_date: #{end_date_obj}, transaction_types: #{transaction_types}, symbol: #{symbol}")
169
+
170
+ transactions_response = client.get_transactions(
171
+ account_hash,
172
+ start_date: start_date_obj,
173
+ end_date: end_date_obj,
174
+ transaction_types: transaction_types,
175
+ symbol: symbol
176
+ )
177
+
178
+ if transactions_response&.body
179
+ log_info("Successfully retrieved transactions for #{account_name}")
180
+ transactions_data = JSON.parse(transactions_response.body)
181
+
182
+ formatted_response = format_transactions_data(transactions_data, account_name, {
183
+ start_date: start_date,
184
+ end_date: end_date,
185
+ transaction_types: transaction_types,
186
+ symbol: symbol
187
+ })
188
+
189
+ MCP::Tool::Response.new([{
190
+ type: "text",
191
+ text: formatted_response
192
+ }])
193
+ else
194
+ log_warn("Empty response from Schwab API for account: #{account_name}")
195
+ MCP::Tool::Response.new([{
196
+ type: "text",
197
+ text: "**No Data**: Empty response from Schwab API for account: #{account_name}"
198
+ }])
199
+ end
200
+
201
+ rescue JSON::ParserError => e
202
+ log_error("JSON parsing error: #{e.message}")
203
+ MCP::Tool::Response.new([{
204
+ type: "text",
205
+ text: "**Error**: Failed to parse API response: #{e.message}"
206
+ }])
207
+ rescue => e
208
+ log_error("Error retrieving transactions for #{account_name}: #{e.message}")
209
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
210
+ MCP::Tool::Response.new([{
211
+ type: "text",
212
+ text: "**Error** retrieving transactions for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
213
+ }])
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ def self.format_transactions_data(transactions_data, account_name, filters)
220
+ friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
221
+
222
+ formatted = "**Transactions for #{friendly_name} (#{account_name}):**\n\n"
223
+
224
+ if filters.any? { |k, v| v }
225
+ formatted += "**Filters Applied:**\n"
226
+ formatted += "- Start Date: #{filters[:start_date]}\n" if filters[:start_date]
227
+ formatted += "- End Date: #{filters[:end_date]}\n" if filters[:end_date]
228
+ formatted += "- Transaction Types: #{filters[:transaction_types].join(', ')}\n" if filters[:transaction_types]&.any?
229
+ formatted += "- Symbol: #{filters[:symbol]}\n" if filters[:symbol]
230
+ formatted += "\n"
231
+ end
232
+
233
+ transactions = transactions_data.is_a?(Array) ? transactions_data : [transactions_data]
234
+
235
+ formatted += "**Transactions Summary:**\n"
236
+ formatted += "- Total Transactions: #{transactions.length}\n\n"
237
+
238
+ if transactions.length > 0
239
+ transactions_by_type = transactions.group_by { |t| t['type'] }
240
+ formatted += "**Transactions by Type:**\n"
241
+ transactions_by_type.each do |type, type_transactions|
242
+ formatted += "- #{type}: #{type_transactions.length} transactions\n"
243
+ end
244
+ formatted += "\n"
245
+
246
+ formatted += "**Transaction Details:**\n"
247
+ transactions.each_with_index do |transaction, index|
248
+ formatted += format_single_transaction(transaction, index + 1)
249
+ formatted += "\n" unless index == transactions.length - 1
250
+ end
251
+ else
252
+ formatted += "No transactions found matching the specified criteria.\n"
253
+ end
254
+
255
+ redacted_data = Redactor.redact(transactions_data)
256
+ formatted += "\n**Full Response (Redacted):**\n"
257
+ formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
258
+ formatted
259
+ end
260
+
261
+ def self.format_single_transaction(transaction, transaction_num)
262
+ formatted = "**Transaction #{transaction_num}:**\n"
263
+ formatted += "- Activity ID: #{transaction['activityId']}\n" if transaction['activityId']
264
+ formatted += "- Type: #{transaction['type']}\n" if transaction['type']
265
+ formatted += "- Status: #{transaction['status']}\n" if transaction['status']
266
+ formatted += "- Trade Date: #{transaction['tradeDate']}\n" if transaction['tradeDate']
267
+ formatted += "- Settlement Date: #{transaction['settlementDate']}\n" if transaction['settlementDate']
268
+ formatted += "- Net Amount: $#{format_currency(transaction['netAmount'])}\n" if transaction['netAmount']
269
+ formatted += "- Sub Account: #{transaction['subAccount']}\n" if transaction['subAccount']
270
+ formatted += "- Order ID: #{transaction['orderId']}\n" if transaction['orderId']
271
+ formatted += "- Position ID: #{transaction['positionId']}\n" if transaction['positionId']
272
+
273
+ if transaction['transferItems'] && transaction['transferItems'].any?
274
+ formatted += "- Transfer Items:\n"
275
+ transaction['transferItems'].each_with_index do |item, i|
276
+ formatted += " * Item #{i + 1}:\n"
277
+ formatted += " - Amount: $#{format_currency(item['amount'])}\n" if item['amount']
278
+ formatted += " - Cost: $#{format_currency(item['cost'])}\n" if item['cost']
279
+ formatted += " - Price: $#{format_currency(item['price'])}\n" if item['price']
280
+ formatted += " - Fee Type: #{item['feeType']}\n" if item['feeType']
281
+ formatted += " - Position Effect: #{item['positionEffect']}\n" if item['positionEffect']
282
+
283
+ if item['instrument']
284
+ instrument = item['instrument']
285
+ formatted += " - Instrument:\n"
286
+ formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
287
+ formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
288
+ formatted += " * Description: #{instrument['description']}\n" if instrument['description']
289
+ formatted += " * Closing Price: $#{format_currency(instrument['closingPrice'])}\n" if instrument['closingPrice']
290
+
291
+ # Options-specific fields
292
+ if instrument['assetType'] == 'OPTION'
293
+ formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
294
+ formatted += " * Put/Call: #{instrument['putCall']}\n" if instrument['putCall']
295
+ formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
296
+ formatted += " * Underlying Symbol: #{instrument['underlyingSymbol']}\n" if instrument['underlyingSymbol']
297
+ end
298
+ end
299
+ end
300
+ end
301
+
302
+ formatted
303
+ end
304
+
305
+ def self.format_currency(amount)
306
+ return "0.00" if amount.nil?
307
+ "%.2f" % amount.to_f
308
+ end
309
+ end
310
+ end
311
+ end