schwab_rb 0.9.2 → 1.0.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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/websocket/client"
5
+ require "json"
6
+ require_relative "services"
7
+ require_relative "fields"
8
+ require_relative "message_builder"
9
+
10
+ module SchwabRb
11
+ module Stream
12
+ class Base
13
+ INITIAL_BACKOFF = 2
14
+ MAX_BACKOFF = 120
15
+ MIN_CONNECTION_TIME = 90
16
+
17
+ attr_reader :client
18
+
19
+ def initialize(client)
20
+ @client = client
21
+ @handlers = {}
22
+ @subscriptions = {}
23
+ @connection = nil
24
+ @message_builder = nil
25
+ @streamer_info = nil
26
+ @connected = false
27
+ @connect_time = nil
28
+ @backoff_time = INITIAL_BACKOFF
29
+ @should_run = false
30
+ end
31
+
32
+ def on(service_symbol, symbols: nil, fields: nil, &block)
33
+ service_name = Services.lookup(service_symbol)
34
+ resolved_fields = fields ? Fields.resolve(service_name, fields) : nil
35
+
36
+ handler = { symbols: Array(symbols), fields: resolved_fields, callback: block }
37
+ @handlers[service_name] ||= []
38
+ @handlers[service_name] << handler
39
+
40
+ @subscriptions[service_name] = merge_subscription(
41
+ @subscriptions[service_name],
42
+ handler
43
+ )
44
+
45
+ send_subscription(service_name, handler) if @connected
46
+
47
+ self
48
+ end
49
+
50
+ def start
51
+ @should_run = true
52
+ Async do |task|
53
+ loop do
54
+ connect
55
+ run_receive_loop
56
+ rescue StandardError => e
57
+ handle_disconnect(e)
58
+ break unless should_reconnect?
59
+
60
+ wait_and_reconnect(task)
61
+ end
62
+ end
63
+ end
64
+
65
+ def stop
66
+ @should_run = false
67
+ disconnect
68
+ end
69
+
70
+ def connected?
71
+ @connected
72
+ end
73
+
74
+ private
75
+
76
+ def connect
77
+ fetch_streamer_info
78
+ open_websocket
79
+ login
80
+ send_pending_subscriptions
81
+ @connected = true
82
+ @connect_time = Time.now
83
+ @backoff_time = INITIAL_BACKOFF
84
+ end
85
+
86
+ def disconnect
87
+ return unless @connection
88
+
89
+ begin
90
+ logout_request = @message_builder.build_logout
91
+ send_message(@message_builder.wrap_requests(logout_request))
92
+ rescue StandardError
93
+ # best-effort logout
94
+ end
95
+
96
+ @connection.close
97
+ @connection = nil
98
+ @connected = false
99
+ end
100
+
101
+ def fetch_streamer_info
102
+ @client.refresh! if @client.session.expired?
103
+ prefs = @client.get_user_preferences
104
+ @streamer_info = prefs.streamer_info.first
105
+
106
+ raise "No streamer info available" unless @streamer_info
107
+
108
+ @message_builder = MessageBuilder.new(
109
+ @streamer_info.schwab_client_customer_id,
110
+ @streamer_info.schwab_client_correl_id
111
+ )
112
+ end
113
+
114
+ def open_websocket
115
+ url = @streamer_info.streamer_socket_url
116
+ endpoint = Async::HTTP::Endpoint.parse(url)
117
+ @connection = Async::WebSocket::Client.connect(endpoint)
118
+ end
119
+
120
+ def login
121
+ access_token = @client.token_manager.token.token
122
+ login_request = @message_builder.build_login(
123
+ access_token,
124
+ @streamer_info.schwab_client_channel,
125
+ @streamer_info.schwab_client_function_id
126
+ )
127
+
128
+ send_message(@message_builder.wrap_requests(login_request))
129
+
130
+ response = @connection.read
131
+ parsed = JSON.parse(response.to_str)
132
+
133
+ login_response = parsed.dig("response", 0) || parsed
134
+ code = login_response.dig("content", "code")
135
+
136
+ return if code.to_i.zero? || code == 0
137
+
138
+ raise "Login failed: #{login_response}"
139
+ end
140
+
141
+ def send_pending_subscriptions
142
+ @subscriptions.each do |service_name, sub|
143
+ request = @message_builder.build_request(
144
+ service_name, Commands::SUBS,
145
+ keys: sub[:symbols],
146
+ fields: sub[:fields]
147
+ )
148
+ send_message(@message_builder.wrap_requests(request))
149
+ end
150
+ end
151
+
152
+ def send_subscription(service_name, handler)
153
+ request = @message_builder.build_request(
154
+ service_name, Commands::ADD,
155
+ keys: handler[:symbols],
156
+ fields: handler[:fields]
157
+ )
158
+ send_message(@message_builder.wrap_requests(request))
159
+ end
160
+
161
+ def run_receive_loop
162
+ while @connected && @should_run
163
+ message = @connection.read
164
+ break if message.nil?
165
+
166
+ parsed = JSON.parse(message.to_str)
167
+ dispatch_message(parsed)
168
+ end
169
+ end
170
+
171
+ def dispatch_message(parsed)
172
+ if parsed.key?("data")
173
+ parsed["data"].each do |data_entry|
174
+ service = data_entry["service"]
175
+ handlers = @handlers[service]
176
+ next unless handlers
177
+
178
+ handlers.each { |h| h[:callback]&.call(data_entry) }
179
+ end
180
+ end
181
+
182
+ return unless parsed.key?("response")
183
+
184
+ parsed["response"].each do |resp|
185
+ SchwabRb::Logger.logger.debug("Stream response: #{resp}")
186
+ end
187
+ end
188
+
189
+ def handle_disconnect(error)
190
+ @connected = false
191
+ begin
192
+ @connection&.close
193
+ rescue StandardError
194
+ nil
195
+ end
196
+ @connection = nil
197
+
198
+ SchwabRb::Logger.logger.warn("Stream disconnected: #{error.message}")
199
+ end
200
+
201
+ def should_reconnect?
202
+ return false unless @should_run
203
+ return false unless @connect_time
204
+
205
+ elapsed = Time.now - @connect_time
206
+ if elapsed < MIN_CONNECTION_TIME
207
+ SchwabRb::Logger.logger.error(
208
+ "Stream connection failed after #{elapsed.round(1)}s (< #{MIN_CONNECTION_TIME}s). " \
209
+ "This likely indicates a configuration error. Not reconnecting."
210
+ )
211
+ return false
212
+ end
213
+
214
+ true
215
+ end
216
+
217
+ def wait_and_reconnect(task)
218
+ SchwabRb::Logger.logger.info("Reconnecting in #{@backoff_time}s...")
219
+ task.sleep(@backoff_time)
220
+ @backoff_time = [@backoff_time * 2, MAX_BACKOFF].min
221
+ end
222
+
223
+ def send_message(json_string)
224
+ @connection.write(Protocol::WebSocket::TextMessage.generate(json_string))
225
+ @connection.flush
226
+ end
227
+
228
+ def merge_subscription(existing, handler)
229
+ if existing
230
+ {
231
+ symbols: (existing[:symbols] + handler[:symbols]).uniq,
232
+ fields: existing[:fields] && handler[:fields] ? (existing[:fields] + handler[:fields]).uniq : nil
233
+ }
234
+ else
235
+ { symbols: handler[:symbols].dup, fields: handler[:fields]&.dup }
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require_relative "base"
5
+
6
+ module SchwabRb
7
+ module Stream
8
+ class Client
9
+ def initialize(client)
10
+ @stream = Base.new(client)
11
+ @thread = nil
12
+ end
13
+
14
+ def on(service_symbol, symbols: nil, fields: nil, &block)
15
+ @stream.on(service_symbol, symbols: symbols, fields: fields, &block)
16
+ self
17
+ end
18
+
19
+ def start
20
+ @thread = Thread.new do
21
+ @stream.start
22
+ end
23
+ @thread.join
24
+ end
25
+
26
+ def start_async
27
+ @thread = Thread.new do
28
+ @stream.start
29
+ end
30
+ self
31
+ end
32
+
33
+ def stop
34
+ @stream.stop
35
+ @thread&.join(5)
36
+ @thread = nil
37
+ end
38
+
39
+ def connected?
40
+ @stream.connected?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchwabRb
4
+ module Stream
5
+ module Fields
6
+ module LevelOneEquity
7
+ SYMBOL = 0
8
+ BID_PRICE = 1
9
+ ASK_PRICE = 2
10
+ LAST_PRICE = 3
11
+ BID_SIZE = 4
12
+ ASK_SIZE = 5
13
+ ASK_ID = 6
14
+ BID_ID = 7
15
+ TOTAL_VOLUME = 8
16
+ LAST_SIZE = 9
17
+ HIGH_PRICE = 10
18
+ LOW_PRICE = 11
19
+ CLOSE_PRICE = 12
20
+ EXCHANGE_ID = 13
21
+ MARGINABLE = 14
22
+ DESCRIPTION = 15
23
+ LAST_ID = 16
24
+ OPEN_PRICE = 17
25
+ NET_CHANGE = 18
26
+ HIGH_PRICE_52_WEEK = 19
27
+ LOW_PRICE_52_WEEK = 20
28
+ PE_RATIO = 21
29
+ DIVIDEND_AMOUNT = 22
30
+ DIVIDEND_YIELD = 23
31
+ NAV = 24
32
+ EXCHANGE_NAME = 25
33
+ DIVIDEND_DATE = 26
34
+ REGULAR_MARKET_QUOTE = 27
35
+ REGULAR_MARKET_TRADE = 28
36
+ REGULAR_MARKET_LAST_PRICE = 29
37
+ REGULAR_MARKET_LAST_SIZE = 30
38
+ REGULAR_MARKET_NET_CHANGE = 31
39
+ SECURITY_STATUS = 32
40
+ MARK = 33
41
+ QUOTE_TIME_MILLIS = 34
42
+ TRADE_TIME_MILLIS = 35
43
+ REGULAR_MARKET_TRADE_MILLIS = 36
44
+ BID_TIME_MILLIS = 37
45
+ ASK_TIME_MILLIS = 38
46
+ ASK_MIC_ID = 39
47
+ BID_MIC_ID = 40
48
+ LAST_MIC_ID = 41
49
+ NET_CHANGE_PERCENT = 42
50
+ REGULAR_MARKET_CHANGE_PERCENT = 43
51
+ MARK_CHANGE = 44
52
+ MARK_CHANGE_PERCENT = 45
53
+ HTB_QUANTITY = 46
54
+ HTB_RATE = 47
55
+ HARD_TO_BORROW = 48
56
+ IS_SHORTABLE = 49
57
+ POST_MARKET_NET_CHANGE = 50
58
+ POST_MARKET_NET_CHANGE_PERCENT = 51
59
+
60
+ ALL = (0..51).to_a.freeze
61
+ end
62
+
63
+ module LevelOneOption
64
+ SYMBOL = 0
65
+ DESCRIPTION = 1
66
+ BID_PRICE = 2
67
+ ASK_PRICE = 3
68
+ LAST_PRICE = 4
69
+ HIGH_PRICE = 5
70
+ LOW_PRICE = 6
71
+ CLOSE_PRICE = 7
72
+ TOTAL_VOLUME = 8
73
+ OPEN_INTEREST = 9
74
+ VOLATILITY = 10
75
+ MONEY_INTRINSIC_VALUE = 11
76
+ EXPIRATION_YEAR = 12
77
+ MULTIPLIER = 13
78
+ DIGITS = 14
79
+ OPEN_PRICE = 15
80
+ BID_SIZE = 16
81
+ ASK_SIZE = 17
82
+ LAST_SIZE = 18
83
+ NET_CHANGE = 19
84
+ STRIKE_TYPE = 20
85
+ CONTRACT_TYPE = 21
86
+ UNDERLYING = 22
87
+ EXPIRATION_MONTH = 23
88
+ DELIVERABLES = 24
89
+ TIME_VALUE = 25
90
+ EXPIRATION_DAY = 26
91
+ DAYS_TO_EXPIRATION = 27
92
+ DELTA = 28
93
+ GAMMA = 29
94
+ THETA = 30
95
+ VEGA = 31
96
+ RHO = 32
97
+ SECURITY_STATUS = 33
98
+ THEORETICAL_OPTION_VALUE = 34
99
+ UNDERLYING_PRICE = 35
100
+ UV_EXPIRATION_TYPE = 36
101
+ MARK = 37
102
+ QUOTE_TIME_MILLIS = 38
103
+ TRADE_TIME_MILLIS = 39
104
+ EXCHANGE_ID = 40
105
+ EXCHANGE_NAME = 41
106
+ LAST_TRADING_DAY = 42
107
+ SETTLEMENT_TYPE = 43
108
+ NET_PERCENT_CHANGE = 44
109
+ MARK_CHANGE = 45
110
+ MARK_CHANGE_PERCENT = 46
111
+ IMPLIED_YIELD = 47
112
+ IS_PENNY = 48
113
+ OPTION_ROOT = 49
114
+ HIGH_PRICE_52_WEEK = 50
115
+ LOW_PRICE_52_WEEK = 51
116
+ INDICATIVE_ASKING_PRICE = 52
117
+ INDICATIVE_BID_PRICE = 53
118
+ INDICATIVE_QUOTE_TIME = 54
119
+ EXERCISE_TYPE = 55
120
+
121
+ ALL = (0..55).to_a.freeze
122
+ end
123
+
124
+ module LevelOneFutures
125
+ SYMBOL = 0
126
+ BID_PRICE = 1
127
+ ASK_PRICE = 2
128
+ LAST_PRICE = 3
129
+ BID_SIZE = 4
130
+ ASK_SIZE = 5
131
+ ASK_ID = 6
132
+ BID_ID = 7
133
+ TOTAL_VOLUME = 8
134
+ LAST_SIZE = 9
135
+ QUOTE_TIME_MILLIS = 10
136
+ TRADE_TIME_MILLIS = 11
137
+ HIGH_PRICE = 12
138
+ LOW_PRICE = 13
139
+ CLOSE_PRICE = 14
140
+ EXCHANGE_ID = 15
141
+ DESCRIPTION = 16
142
+ LAST_ID = 17
143
+ OPEN_PRICE = 18
144
+ NET_CHANGE = 19
145
+ FUTURE_PERCENT_CHANGE = 20
146
+ EXCHANGE_NAME = 21
147
+ SECURITY_STATUS = 22
148
+ OPEN_INTEREST = 23
149
+ MARK = 24
150
+ TICK = 25
151
+ TICK_AMOUNT = 26
152
+ PRODUCT = 27
153
+ FUTURE_PRICE_FORMAT = 28
154
+ FUTURE_TRADING_HOURS = 29
155
+ FUTURE_IS_TRADABLE = 30
156
+ FUTURE_MULTIPLIER = 31
157
+ FUTURE_IS_ACTIVE = 32
158
+ FUTURE_SETTLEMENT_PRICE = 33
159
+ FUTURE_ACTIVE_SYMBOL = 34
160
+ FUTURE_EXPIRATION_DATE = 35
161
+ EXPIRATION_STYLE = 36
162
+ ASK_MIC_ID = 37
163
+ BID_MIC_ID = 38
164
+ LAST_MIC_ID = 39
165
+ SETTLEMENT_DATE = 40
166
+
167
+ ALL = (0..40).to_a.freeze
168
+ end
169
+
170
+ module LevelOneFuturesOptions
171
+ SYMBOL = 0
172
+ BID_PRICE = 1
173
+ ASK_PRICE = 2
174
+ LAST_PRICE = 3
175
+ BID_SIZE = 4
176
+ ASK_SIZE = 5
177
+ BID_ID = 6
178
+ ASK_ID = 7
179
+ TOTAL_VOLUME = 8
180
+ LAST_SIZE = 9
181
+ QUOTE_TIME_MILLIS = 10
182
+ TRADE_TIME_MILLIS = 11
183
+ HIGH_PRICE = 12
184
+ LOW_PRICE = 13
185
+ CLOSE_PRICE = 14
186
+ LAST_ID = 15
187
+ DESCRIPTION = 16
188
+ OPEN_PRICE = 17
189
+ OPEN_INTEREST = 18
190
+ MARK = 19
191
+ TICK = 20
192
+ TICK_AMOUNT = 21
193
+ FUTURE_MULTIPLIER = 22
194
+ FUTURE_SETTLEMENT_PRICE = 23
195
+ UNDERLYING_SYMBOL = 24
196
+ STRIKE_PRICE = 25
197
+ FUTURE_EXPIRATION_DATE = 26
198
+ EXPIRATION_STYLE = 27
199
+ CONTRACT_TYPE = 28
200
+ SECURITY_STATUS = 29
201
+ EXCHANGE_ID = 30
202
+ EXCHANGE_NAME = 31
203
+
204
+ ALL = (0..31).to_a.freeze
205
+ end
206
+
207
+ module LevelOneForex
208
+ SYMBOL = 0
209
+ BID_PRICE = 1
210
+ ASK_PRICE = 2
211
+ LAST_PRICE = 3
212
+ BID_SIZE = 4
213
+ ASK_SIZE = 5
214
+ TOTAL_VOLUME = 6
215
+ LAST_SIZE = 7
216
+ QUOTE_TIME_MILLIS = 8
217
+ TRADE_TIME_MILLIS = 9
218
+ HIGH_PRICE = 10
219
+ LOW_PRICE = 11
220
+ CLOSE_PRICE = 12
221
+ EXCHANGE_ID = 13
222
+ DESCRIPTION = 14
223
+ OPEN_PRICE = 15
224
+ NET_CHANGE = 16
225
+ CHANGE_PERCENT = 17
226
+ EXCHANGE_NAME = 18
227
+ DIGITS = 19
228
+ SECURITY_STATUS = 20
229
+ TICK = 21
230
+ TICK_AMOUNT = 22
231
+ PRODUCT = 23
232
+ TRADING_HOURS = 24
233
+ IS_TRADABLE = 25
234
+ MARKET_MAKER = 26
235
+ HIGH_PRICE_52_WEEK = 27
236
+ LOW_PRICE_52_WEEK = 28
237
+ MARK = 29
238
+
239
+ ALL = (0..29).to_a.freeze
240
+ end
241
+
242
+ module Book
243
+ SYMBOL = 0
244
+ BOOK_TIME = 1
245
+ BIDS = 2
246
+ ASKS = 3
247
+
248
+ ALL = (0..3).to_a.freeze
249
+ end
250
+
251
+ module BookBid
252
+ BID_PRICE = 0
253
+ TOTAL_VOLUME = 1
254
+ NUM_BIDS = 2
255
+ BIDS = 3
256
+ end
257
+
258
+ module BookAsk
259
+ ASK_PRICE = 0
260
+ TOTAL_VOLUME = 1
261
+ NUM_ASKS = 2
262
+ ASKS = 3
263
+ end
264
+
265
+ module BookExchange
266
+ EXCHANGE = 0
267
+ VOLUME = 1
268
+ SEQUENCE = 2
269
+ end
270
+
271
+ module ChartEquity
272
+ SYMBOL = 0
273
+ SEQUENCE = 1
274
+ OPEN_PRICE = 2
275
+ HIGH_PRICE = 3
276
+ LOW_PRICE = 4
277
+ CLOSE_PRICE = 5
278
+ VOLUME = 6
279
+ CHART_TIME_MILLIS = 7
280
+ CHART_DAY = 8
281
+
282
+ ALL = (0..8).to_a.freeze
283
+ end
284
+
285
+ module ChartFutures
286
+ SYMBOL = 0
287
+ CHART_TIME_MILLIS = 1
288
+ OPEN_PRICE = 2
289
+ HIGH_PRICE = 3
290
+ LOW_PRICE = 4
291
+ CLOSE_PRICE = 5
292
+ VOLUME = 6
293
+
294
+ ALL = (0..6).to_a.freeze
295
+ end
296
+
297
+ module Screener
298
+ SYMBOL = 0
299
+ TIMESTAMP = 1
300
+ SORT_FIELD = 2
301
+ FREQUENCY = 3
302
+ ITEMS = 4
303
+
304
+ ALL = (0..4).to_a.freeze
305
+ end
306
+
307
+ module AccountActivity
308
+ SUBSCRIPTION_KEY = 0
309
+ ACCOUNT = 1
310
+ MESSAGE_TYPE = 2
311
+ MESSAGE_DATA = 3
312
+
313
+ ALL = (0..3).to_a.freeze
314
+ end
315
+
316
+ SERVICE_FIELDS = {
317
+ "LEVELONE_EQUITIES" => LevelOneEquity,
318
+ "LEVELONE_OPTIONS" => LevelOneOption,
319
+ "LEVELONE_FUTURES" => LevelOneFutures,
320
+ "LEVELONE_FUTURES_OPTIONS" => LevelOneFuturesOptions,
321
+ "LEVELONE_FOREX" => LevelOneForex,
322
+ "NYSE_BOOK" => Book,
323
+ "NASDAQ_BOOK" => Book,
324
+ "OPTIONS_BOOK" => Book,
325
+ "CHART_EQUITY" => ChartEquity,
326
+ "CHART_FUTURES" => ChartFutures,
327
+ "SCREENER_EQUITY" => Screener,
328
+ "SCREENER_OPTION" => Screener,
329
+ "ACCT_ACTIVITY" => AccountActivity
330
+ }.freeze
331
+
332
+ def self.resolve(service, fields)
333
+ return SERVICE_FIELDS[service]::ALL if fields == :all
334
+
335
+ Array(fields).map(&:to_i)
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SchwabRb
6
+ module Stream
7
+ class MessageBuilder
8
+ def initialize(customer_id, correl_id)
9
+ @customer_id = customer_id
10
+ @correl_id = correl_id
11
+ @request_id = 0
12
+ end
13
+
14
+ def build_login(access_token, channel, function_id)
15
+ {
16
+ "service" => "ADMIN",
17
+ "command" => Commands::LOGIN,
18
+ "requestid" => next_request_id,
19
+ "SchwabClientCustomerId" => @customer_id,
20
+ "SchwabClientCorrelId" => @correl_id,
21
+ "parameters" => {
22
+ "Authorization" => "Bearer #{access_token}",
23
+ "SchwabClientChannel" => channel,
24
+ "SchwabClientFunctionId" => function_id
25
+ }
26
+ }
27
+ end
28
+
29
+ def build_logout
30
+ {
31
+ "service" => "ADMIN",
32
+ "command" => Commands::LOGOUT,
33
+ "requestid" => next_request_id,
34
+ "SchwabClientCustomerId" => @customer_id,
35
+ "SchwabClientCorrelId" => @correl_id
36
+ }
37
+ end
38
+
39
+ def build_request(service, command, keys: nil, fields: nil)
40
+ request = {
41
+ "service" => service,
42
+ "command" => command,
43
+ "requestid" => next_request_id,
44
+ "SchwabClientCustomerId" => @customer_id,
45
+ "SchwabClientCorrelId" => @correl_id,
46
+ "parameters" => {}
47
+ }
48
+
49
+ request["parameters"]["keys"] = Array(keys).join(",") if keys
50
+ request["parameters"]["fields"] = Fields.resolve(service, fields).join(",") if fields
51
+
52
+ request
53
+ end
54
+
55
+ def wrap_requests(*requests)
56
+ { "requests" => requests.flatten }.to_json
57
+ end
58
+
59
+ private
60
+
61
+ def next_request_id
62
+ @request_id += 1
63
+ end
64
+ end
65
+ end
66
+ end